from fastcore.test import test_eq, test_fail, test_close
a = UsageStats(prompt_tokens=10, completion_tokens=5, total_tokens=15, n=1)
b = UsageStats(prompt_tokens=3, completion_tokens=2, total_tokens=20, n=1)
c = a + b
test_eq((c.prompt_tokens, c.completion_tokens, c.n), (13, 7, 2))
assert "in=13" in repr(c) and "out=7" in repr(c)
test_eq((a + None).prompt_tokens, 10)
# cost/model default to 0/None and stay out of the repr until something sets them
test_eq((a.cost, a.model), (0.0, None))
assert 'cost' not in repr(a) and 'model' not in repr(a)
d = UsageStats(prompt_tokens=1, cost=0.02, model='gemma-4-e2b') + UsageStats(prompt_tokens=1, cost=0.03)
test_eq(d.cost, 0.05)
test_eq(d.model, 'gemma-4-e2b') # first non-None model wins
assert 'cost=$0.0500' in repr(d) and 'model=gemma-4-e2b' in repr(d)
# cached_tokens sums like the other counters, and only shows up once a backend reports one
test_eq(a.cached_tokens, 0)
assert 'cached' not in repr(a)
e = UsageStats(prompt_tokens=100, cached_tokens=90) + UsageStats(prompt_tokens=100, cached_tokens=80)
test_eq(e.cached_tokens, 170)
assert 'cached=170' in repr(e)core
Chat: dispatch by model name, callbacks, HITL tools, streaming, structured output, usage tracking.
Usage tracking
UsageStats tallies prompt/completion/cached tokens per turn. chat.use is the last turn; backends merge into chat.usage. cost/model are always set so local and hosted usage share one type.
UsageStats
def UsageStats(
prompt_tokens:int=0, completion_tokens:int=0, total_tokens:int=0, n:int=0, cached_tokens:int=0, cost:float=0.0,
model:NoneType=None
):Token usage for a chat turn. cached_tokens is the part of the prompt served from a KV/prefix cache. cost/model are always present (cost is 0 for local inference) so a harness merging local and hosted usage can carry one type instead of two.
Callbacks
Subclass ChatCallback, hook before_send / after_response / before_tool_calls / after_tool_calls. order controls sequencing; run_cbs dispatches. State lives on self.chat via GetAttr.
run_cbs
def run_cbs(
chat, event
):Dispatch event to enabled callbacks in order; forward any yielded stream items.
ChatCallback
def ChatCallback(
*args, **kwargs
):Base chat callback; reads chat state via GetAttr (self.turn_msg -> chat.turn_msg).
class _Dummy: pass
class _A(ChatCallback):
order = 20
def before_send(self): self.chat.log.append('A')
class _B(ChatCallback):
order = 10
def before_send(self):
self.chat.log.append('B')
yield {'text': 'from-B'}
d = _Dummy(); d.log = []
a, b = _A(), _B(); a.chat = d; b.chat = d
d.cbs = L(a, b)
out = list(run_cbs(d, 'before_send'))
test_eq(d.log, ['B', 'A'])
test_eq(out, [{'text': 'from-B'}])
test_eq(repr(a), '_A')Streaming display
StreamFormatter turns token chunks into markdown for notebooks. display_stream(gen) renders live; adisplay_stream is the async twin.
display_stream
def display_stream(
chunks
):Progressively render a markdown-chunk stream (e.g. chat(msg, stream=True)) live in a notebook; returns the full markdown.
StreamFormatter
def StreamFormatter(
mx:int=2000, showthink:bool=True
):Format a litert response stream to markdown; thinking streams as a blockquote.
mk_tr_details
def mk_tr_details(
name, args, result, mx:int=2000
):<details> JSON block for a completed tool call.
tc_summary_
def tc_summary_(
name, args, result:NoneType=None
):One-line <code> summary of a tool call.
Resp
def Resp(
*args, **kwargs
):A litert response dict that renders as markdown (thinking + text + tool calls/responses) in notebooks.
quote_
def quote_(
text
):Render text as a markdown blockquote with a Thinking header (renders in any md engine).
thought
def thought(
resp
):The model’s thinking (channels.thought) for a litert response/chunk, or ’’.
resp_text
def resp_text(
resp
):Join text parts of a litert response/chunk dict.
Built-in callbacks
UsageCallback accumulates tokens. ToolReminderCallback nudges the model when tools exist but weren’t called. TruncationCallback clips over-long tool results before they hit the context.
TruncationCallback
def TruncationCallback(
max_tokens
):Flag turn_res['truncated'] when the reply reaches max_tokens output tokens (best-effort).
truncated
def truncated(
resp
):Whether resp was flagged as cut off at the token cap.
OpenAI-style messages
Helpers to build OpenAI-shaped message dicts (mk_oai_content, mk_oai_msg, mk_oai_msgs) used by llama.cpp and as the canonical interchange format. Media items become placeholders in past turns via strip_media.
is_media
def is_media(
p
):Is p an image or audio content part?
mk_oai_msgs
def mk_oai_msgs(
msgs
):Normalize a list of messages to OpenAI-style dicts.
mk_oai_msg
def mk_oai_msg(
content, role:str='user'
):Create an OpenAI-style message dict from str/bytes/list/dict.
mk_oai_content
def mk_oai_content(
o
):Convert o to an OpenAI-style content part (text, a base64 image_url, or an input_audio).
from fastcore.test import test_eq, test_failtest_eq(mk_oai_msg("hello"), {'role': 'user', 'content': 'hello'})
test_eq(mk_oai_msg(["a", "b"]), {'role': 'user', 'content': 'a\nb'})
test_eq(mk_oai_msg({'role': 'assistant', 'content': 'hi'}), {'role': 'assistant', 'content': 'hi'})
test_eq(mk_oai_msgs(['hi', {'role': 'assistant', 'content': 'hello'}]),
[{'role': 'user', 'content': 'hi'}, {'role': 'assistant', 'content': 'hello'}])
png = b'\x89PNG\r\n\x1a\n' + b'0' * 8
assert mk_oai_msg([png, 'what is this?'])['content'][0]['type'] == 'image_url'
test_eq(mk_oai_content(b'RIFF0000WAVE')['type'], 'input_audio') # audio is sniffed too
test_fail(lambda: mk_oai_content(b'%PDF-1.4 junk'), contains='text, image, and audio')Tool schemas
mk_toolspec(fn) reads signatures and docstrings into a JSON schema for the model.
mk_toolspec
def mk_toolspec(
f
):OpenAI-style tool spec for callable f (via fastcore.funccall.get_schema); spec dicts pass through.
def _add(
a:int, # first addend
b:int=0 # second addend
):
'Add two integers.'
return a + b
s = mk_toolspec(_add)
test_eq(s['type'], 'function')
test_eq(s['function']['name'], '_add')
test_eq(s['function']['parameters']['required'], ['a'])
test_eq(mk_toolspec(s), s) # spec dicts pass through
# `get_schema` adds a stray `title` for classes/dataclasses (the `structured` path); it must not reach
# the rendered tool listing or llama.cpp's JSON-schema grammar builder
from dataclasses import dataclass
@dataclass
class _Point:
'A 2d point'
x: int # the x coord
y: int # the y coord
p = mk_toolspec(_Point)
test_eq(p['function']['name'], '_Point')
test_eq(p['function']['parameters']['required'], ['x', 'y'])
assert 'title' not in p['function']['parameters'], p['function']['parameters']Response normalization
norm_resp maps backend completion dicts to Resp. to_oai_msg converts canonical history entries back to wire messages.
strip_media
def strip_media(
m
):Replace media parts with a text placeholder and collapse content to a string, so past-turn media isn’t re-encoded.
sum_usage
def sum_usage(
us
):Sum OpenAI usage dicts (ignoring Nones); None if nothing to sum. A model name, if any, is carried through rather than summed.
to_oai_msg
def to_oai_msg(
m
):Project a history entry to an OpenAI-style wire message: drop rishi-only keys, re-encode tool-call args as JSON.
norm_resp
def norm_resp(
r
):Normalize an OpenAI-style chat completion to a rishi-style Resp dict.
parse_args
def parse_args(
a
):Parse OpenAI JSON-string tool arguments to a dict (dicts pass through).
raw = {'choices': [{'message': {'role': 'assistant',
'content': '<think>2+3</think>The answer is 5.\n<tool_call>{"name": "add", "arguments": {"a": 2, "b": 3}}</tool_call>',
'tool_calls': [{'id': 'x1', 'type': 'function',
'function': {'name': 'mul', 'arguments': '{"a": 4}'}}]},
'finish_reason': 'stop'}],
'usage': {'prompt_tokens': 10, 'completion_tokens': 5, 'total_tokens': 15}}
r = norm_resp(raw)
test_eq(resp_text(r), 'The answer is 5.')
test_eq(thought(r), '2+3')
test_eq([tc['function']['name'] for tc in r['tool_calls']], ['mul', 'add'])
test_eq(r['tool_calls'][0]['function']['arguments'], {'a': 4})
assert not truncated(r)
o = to_oai_msg(r)
assert 'channels' not in o and 'usage' not in o
test_eq(o['tool_calls'][0]['function']['arguments'], '{"a": 4}')
test_eq(to_oai_msg({'role': 'tool', 'tool_call_id': 't1', 'name': 'add', 'content': '5'}),
{'role': 'tool', 'tool_call_id': 't1', 'name': 'add', 'content': '5'})
test_eq(sum_usage([{'prompt_tokens': 1, 'completion_tokens': 2, 'total_tokens': 3}, None,
{'prompt_tokens': 10, 'completion_tokens': 20, 'total_tokens': 30}]),
{'prompt_tokens': 11, 'completion_tokens': 22, 'total_tokens': 33, 'cached_tokens': 0})
test_eq(sum_usage([{'cached_tokens': 5}, {'cached_tokens': 7}])['cached_tokens'], 12)
test_eq(sum_usage([None, None]), None)
test_eq(sum_usage([{'model': 'a'}, {'model': 'b'}])['model'], 'a') # carried, not summed
assert 'model' not in sum_usage([{'prompt_tokens': 1}])Streaming text splitter
StreamSplit incrementally splits a token stream on think/tool/answer boundaries for live display.
acc_tc
def acc_tc(
acc, deltas
):Fold streamed OpenAI tool_calls deltas into acc (a list of partial tool_call dicts).
StreamSplit
def StreamSplit():Stateful splitter for streamed text: <think> -> thought chunks, <tool_call> blocks held back and parsed.
def _run_split(deltas):
sp, chunks = StreamSplit(), []
for d in deltas: chunks += list(sp.feed(d))
chunks += list(sp.finish())
return sp, chunks
# tags split across chunk boundaries
sp, chunks = _run_split(['<thi', 'nk>let me', ' see</th', 'ink>\n\nans', 'wer: 5 <tool', '_call>{"name": "add", "arguments": {"a": 1}}</tool_call>'])
test_eq(sp.thought, 'let me see')
test_eq(sp.text, 'answer: 5 ')
test_eq(sp.tool_calls[0]['function'], {'name': 'add', 'arguments': {'a': 1}})
assert all(('channels' in c) or ('content' in c) for c in chunks)
# plain text passes through; unterminated think flushes to thought
sp, _ = _run_split(['just ', 'text'])
test_eq((sp.text, sp.thought, sp.tool_calls), ('just text', '', []))
sp, _ = _run_split(['<think>never closed'])
test_eq(sp.thought, 'never closed')
# a lone '<' that never becomes a tag is still emitted
sp, _ = _run_split(['a < b', ' and c'])
test_eq(sp.text, 'a < b and c')
acc = []
acc_tc(acc, [{'index': 0, 'id': 'c1', 'function': {'name': 'add', 'arguments': ''}}])
acc_tc(acc, [{'index': 0, 'function': {'arguments': '{"a"'}}])
acc_tc(acc, [{'index': 0, 'function': {'arguments': ': 2}'}}])
test_eq(acc, [{'id': 'c1', 'type': 'function', 'function': {'name': 'add', 'arguments': '{"a": 2}'}}])Chat and dispatch
Reusing a token prefix
common_prefix_len finds shared token/id prefixes so backends can reuse KV state between turns.
common_prefix_len
def common_prefix_len(
a, b
):Length of the longest common prefix of sequences a and b.
test_eq(common_prefix_len([1, 2, 3, 4], [1, 2, 9]), 2)
test_eq(common_prefix_len([1, 2], [1, 2, 3]), 2) # one is a prefix of the other
test_eq(common_prefix_len([], [1, 2]), 0)
test_eq(common_prefix_len([9], [1]), 0)get_runtime
def get_runtime(
nm
):The Chat subclass for runtime nm (imports the backend module lazily).
resolve_runtime
def resolve_runtime(
model:NoneType=None, runtime:NoneType=None, model_path:NoneType=None
):Resolve (runtime, model) from an explicit runtime, a runtime/ prefix, or the id/path shape.
infer_runtime
def infer_runtime(
model
):Guess a runtime from the shape of a model id or path (.litertlm vs .gguf), else None.
split_runtime
def split_runtime(
model
):Split 'runtime/model' into (runtime, model); the prefix must name a known runtime, else (None, model).
Model capabilities
model_caps(model) says what a model accepts and hands back without loading it, so a picker can label everything it lists. Cloud from fastllm’s bundled model table, GGUF from an mmproj beside the weights, MLX from the towers in config.json.
Caps.source records who answered — the table leaves the modality fields empty on rows it still carries supports_vision for, so a caller needs to tell unknown from no.
model_caps
def model_caps(
model:NoneType=None, runtime:NoneType=None, model_path:NoneType=None
):What model accepts and what it returns, resolved without loading it.
Caps
def Caps(
inp:tuple=('text',), out:tuple=('text',), tools:tuple=(), source:str='default'
)->None:What a model can be sent, and what it can send back.
c = Caps(('text', 'image'), ('text', 'image'), source='litellm')
test_eq((c.gen_image, c.gen_video, c.known), (True, False, True))
test_eq(c.fmt(), 'in: text image \u00b7 out: text image')
assert c.accepts('image') and not c.accepts('audio')
test_eq(Caps(source='litellm').fmt(), '')
test_eq(Caps().known, False)
test_eq(Caps().fmt(), 'modalities unknown')
# a chat model reaches image generation through the Responses tool, which the table's
# `supported_output_modalities` never says -- gpt-5.6-luna reports text-out and draws anyway
luna = _tbl_caps('openai/gpt-5.6-luna')
test_eq(luna.out, ('text',))
test_eq(luna.tools, ('image',))
assert luna.gen_image
assert 'via tool: image' in luna.fmt()
test_eq(_fallback_caps('claude-opus-4-5'), Caps(('text', 'image'), ('text',), (), 'fallback'))
test_eq(_fallback_caps('gpt-5'), None)
test_eq(model_caps(runtime='litert', model='google/gemma-3n-E2B-it-litert-lm').inp,
('text', 'image', 'audio'))Tool calls and tool results
ToolCall records name/args/id. mk_tool_res_msg builds the tool-result message appended after execution.
mk_tool_res_msgs
def mk_tool_res_msgs(
tcs, results
):Canonical tool-result messages for several calls at once.
mk_tool_res_msg
def mk_tool_res_msg(
tc, result
):Canonical role='tool' message carrying result back for tool call tc.
tc_name
def tc_name(
tc
):Name of a tool call, however it was built.
ToolCall
def ToolCall(
name:str='', arguments:NoneType=None, id:NoneType=None, server:bool=False
):One tool call in canonical form. A dict subclass, so anything that indexes it keeps working.
tc = ToolCall('add', {'a': 1, 'b': 2})
test_eq((tc.name, tc.arguments), ('add', {'a': 1, 'b': 2}))
test_eq(tc['function']['name'], 'add') # still just a dict underneath
assert tc['id'].startswith('call_')
test_eq(ToolCall('x', id='fixed')['id'], 'fixed')
test_eq(tc.server, False)
assert 'server' not in tc # not carried onto the wire unless it's true
assert ToolCall('web_search', server=True).server
test_eq(mk_tool_res_msg(tc, 3),
{'role': 'tool', 'tool_call_id': tc['id'], 'name': 'add', 'content': '3'})
test_eq([m['content'] for m in mk_tool_res_msgs([tc, tc], [1, 2])], ['1', '2'])
test_eq(tc_name({'function': {'name': 'f'}}), 'f'); test_eq(tc_name({}), '')Tool-call budget and context recovery
max_steps caps tool calls per turn; past the cap calls are denied and final_prompt asks for a prose answer.
If the context overflows mid-turn, recover_context truncates oldest tool results, rebuilds backend state, and retries once before raising ContextWindowExceededError.
is_ctx_error
def is_ctx_error(
chat, e
):Best-effort: does e, raised from a backend step, look like the context window filling up?
ContextWindowExceededError
def ContextWindowExceededError(
*args, **kwargs
):Raised when a turn can’t finish because the context window filled up and truncate-and-retry recovery also failed.
Chat
def Chat(
*args, **kwargs
):Backend-agnostic chat: Chat(model) dispatches to the litert/llama/mlx subclass by runtime/model shape.
Reconfigure and one-shots
reconfigure(sp=, tools=) changes briefing or tools without clearing hist.
oneshot(prompt, …) is a stateless side call (classify-sized jobs). think=False matters on reasoning models with tiny max_tokens budgets.
The tool loop
ToolLoopMixin runs the Python-side tool loop for llama/MLX/cursor: approval, budget, optional parallel_tools, and context recovery. Backends supply _model_step / _stream_step and a tool namespace.
ToolLoopMixin
def ToolLoopMixin(
*args, **kwargs
):The Python-side tool loop, for backends that get tool calls back as data (llama, mlx).
The backend supplies one wire call - _model_step(max_output_tokens) -> Resp and _stream_step(max_output_tokens), a generator of chunk dicts that leaves the merged Resp on self._step_res - plus a ns tool namespace. Backends holding conversation state of their own also override _recreate_conv.
Keeping the context window from filling up
SlidingWindowCallback drops whole message groups from the middle of hist when pct_full crosses a threshold — keeping early and recent turns, never splitting a tool call from its result. summarize=True replaces evicted turns with one summary call. Registered by default on every backend.
SlidingWindowCallback
def SlidingWindowCallback(
threshold:float=0.9, # evict once the context is this full
keep_first:int=2, # leading groups to anchor (the task, usually)
keep_last:int=8, # trailing groups to keep (the live thread)
summarize:bool=False, # spend one model call to replace the dropped middle with a summary
mx:int=4000, # chars of dropped conversation to feed the summarizer
):Evict the middle of hist before a turn that would overflow the context window.
Needs chat.ctx_limit to be set - without a limit there is nothing to measure pct_full against, and the callback stays out of the way.
evict_middle
def evict_middle(
hist, keep_first:int=2, keep_last:int=8
):Drop whole groups from the middle of hist, keeping the first and last few. Returns (new_hist, dropped_msgs).
msg_groups
def msg_groups(
hist
):Split hist into atomic groups: an assistant message with tool_calls stays with the tool results that answer it.
Testing the tool loop without a model
The hide cells below drive ToolLoopMixin with a fake backend — no weights required.
adisplay_stream
async def adisplay_stream(
chunks
):Async twin of display_stream: drive an async chunk stream - or the awaitable that yields one (achat(msg, stream=True)) - to completion, live-rendering in a notebook when possible; returns the full markdown.
AsyncChat
def AsyncChat(
model:NoneType=None, runtime:NoneType=None, **kw
):Async twin of Chat for either backend; blocking calls run in a worker thread.
Draining an async stream
AsyncChat(msg, stream=True) returns an async iterator; .value holds the final Resp. Sync: wrap chat(msg, stream=True) in SaveReturn and read .value after iteration.
Managing callbacks
add_cb, remove_cb (by instance or class), and per-call cbs= on a single turn.
Human-in-the-loop tool approval
approve(tool_call) -> bool runs before each tool. hitl_policy maps tool names to approved / dont_run / check. browser=True routes check to Leela’s approval card (LEELA_URL, default http://127.0.0.1:5001).
hitl_policy
def hitl_policy(
modes, ask:NoneType=None, browser:bool=False
):Build an approve(tool_call) from per-tool modes; optionally ask in the Leela browser.
browser_approval
def browser_approval(
url:NoneType=None, timeout:int=300
):An approval callback that asks through a running Leela web IDE.
Running python from replies
PyFenceCallback runs the last python fence through `safepyrun`, feeds aresult block back, and loops until prose or done. Same approve gate as tools. chat.run_py(code) executes directly in the persistent sandbox.
PyFenceCallback
def PyFenceCallback(
max_rounds:int=5, done:NoneType=None, pyrun:callable=None
):Run ``python fences, feed results back, loop untildone(chat)(default: no fence).max_rounds` is a safety cap.
output_matches
def output_matches(
expected
):done policy: stop once the last code output contains str(expected) (e.g. the answer to match against).
task_complete
def task_complete(
chat
):done policy: judge (via classify, isolated) whether the latest result completes the request.
Chat.run_py
def run_py(
code, ban_defs:bool=False, g:NoneType=None
):Run code in this chat’s sandboxed, persistent namespace; return stdout + last-expr repr.
sync_iter
def sync_iter(
agen_fn
):Drive the async generator returned by agen_fn() from sync code, yielding its items.
The whole generator runs on one event loop in one background thread, rather than a fresh loop per item: a streaming HTTP response is bound to the loop that opened it, so pumping it with repeated asyncio.run calls would tear the connection down mid-stream.
run_coro
def run_coro(
coro
):Run an awaitable to completion from sync code, even inside a running event loop.
mk_result_fence
def mk_result_fence(
out
):Feed a code result back, prompting a prose answer or more code.
matches_
def matches_(
actual, expected
):True if actual contains any value in expected (a scalar, or a list of accepted values).
extract_fence
def extract_fence(
text, tag:str='answer'
):Contents of the last ``<tag> fence intext`, else the whole stripped text.
extract_code
def extract_code(
text
):Code of the last ``python fence intext`, else None.
Utilities: classify, structured
classify and structured run isolated one-shot turns on the same engine without touching hist.
Chat.structured
def structured(
prompt, schema, sp:str='Reply with only a JSON object matching the schema.'
):One-shot structured output; returns schema(...), rebuilding nested dataclasses.
Chat.classify
def classify(
text, labels, sp:str='Reply with only the single best label and nothing else.'
):One-shot label for text, run stateless (isolated from the conversation).
Grading answers
chat.check(question, expected) pulls an ``answer fence and grades with [matches_](https://vedicreader.github.io/rishi/core.html#matches_) by default. Passgrade_fn,llm_judge=True, orjudge=another [Chat`](https://vedicreader.github.io/rishi/core.html#chat) for model grading.
Chat.check
def check(
question, expected, grade_fn:function=matches_, llm_judge:bool=False, judge:NoneType=None, tag:str='answer',
sp:str='Answer the question, then put your final answer inside a ```answer fence.'
):Ask question stateless, extract the ``<tag> answer, and grade it againstexpected`.
Chat.grades
def grades(
question, expected, actual
):LLM-as-judge (on this chat’s engine): is actual a correct answer to question given reference expected?
from fastcore.test import test_eq, test_fail
test_eq(split_runtime('llama/Qwen/Qwen3-4B-GGUF'), ('llama', 'Qwen/Qwen3-4B-GGUF'))
test_eq(split_runtime('litert-community/gemma-4-E2B-it-litert-lm'), (None, 'litert-community/gemma-4-E2B-it-litert-lm'))
test_eq(infer_runtime('Qwen/Qwen3-0.6B-GGUF'), 'llama'); test_eq(infer_runtime('/m/x.litertlm'), 'litert')
test_eq(infer_runtime('mlx-community/Qwen3-4B-4bit'), 'mlx')
test_eq(split_runtime('mlx/Qwen3-4B'), ('mlx', 'Qwen3-4B'))
test_eq(resolve_runtime('llama/my'), ('llama','my')); test_eq(resolve_runtime(), ('litert', None))
test_eq(resolve_runtime('mlx-community/Qwen3-4B-4bit')[0], 'mlx')
test_fail(lambda: resolve_runtime('gemma-4-E2B'), contains="Can't tell which backend")
test_fail(lambda: resolve_runtime('x', runtime='vllm'), contains='Unknown runtime')
from dataclasses import dataclass
@dataclass
class _A: year:int; month:int
@dataclass
class _P: name:str; age:_A
test_eq(_mk_obj(_P, {'name':'Alice','age':{'year':1995,'month':6}}).age, _A(1995,6))
class _FakeChat(Chat):
_runtime='litert'
def __init__(self, **kw): self.ctx_limit=100; self._setup(**kw)
def mk_msgs(self, msgs): return list(msgs or [])
def _oneshot(self, prompt, sp='', think=None, max_tokens=None):
self.asked = think; return 'positive because good'
def _structured_call(self, prompt, schema, sp): return {'name':'Alice','age':{'year':1995,'month':6}}
def _send(self, msg, mot=None): return Resp({'role':'assistant','content':'ok'})
def close(self): pass
c=_FakeChat(sp='hi')
test_eq(c.classify('great', ['positive','negative']), 'positive')
test_eq(c.asked, False) # a label is a cheap job, so: don't think
test_eq(c.oneshot('hi', think=True, max_tokens=8), 'positive because good')
test_eq(c.structured('x', _P).age, _A(1995,6))
test_eq(run_coro(AsyncChat(c)('go')), Resp({'role':'assistant','content':'ok'}))
# dispatch runs the *subclass's* __new__, so a backend can re-route further (mlx -> mlx-vlm)
import sys, types
class _FakeSub(Chat):
_runtime = 'fakert'
def __new__(cls, model=None, **kw): return object.__new__(_FakeDeeper if kw.get('deep') else cls)
class _FakeDeeper(_FakeSub): pass
_m = types.ModuleType('_fake_backend'); _m._FakeSub = _FakeSub
sys.modules['_fake_backend'] = _m
runtimes['fakert'] = ('_fake_backend', '_FakeSub')
try:
test_eq(type(Chat.__new__(Chat, 'x', runtime='fakert')), _FakeSub)
test_eq(type(Chat.__new__(Chat, 'x', runtime='fakert', deep=True)), _FakeDeeper)
test_eq(type(_FakeSub.__new__(_FakeSub)), _FakeSub) # direct construction is untouched
finally: del runtimes['fakert'], sys.modules['_fake_backend']
# hosted model names route to the remote backend, and local shapes still win
test_eq(infer_runtime('claude-sonnet-4-5'), 'remote')
test_eq(infer_runtime('gpt-5.5'), 'remote')
test_eq(infer_runtime('anthropic/claude-opus-4-5'), 'remote')
test_eq(infer_runtime('mlx-community/Qwen3-4B-4bit'), 'mlx') # a local repo, not a hosted name
test_eq(split_runtime('remote/claude-sonnet-4-5'), ('remote', 'claude-sonnet-4-5'))
# sync_iter drains an async generator from sync code, and propagates its exceptions
async def _agen():
for i in range(3): yield i
test_eq(list(sync_iter(_agen)), [0, 1, 2])
async def _aboom():
yield 1
raise ValueError('boom')
test_fail(lambda: list(sync_iter(_aboom)), contains='boom')Recording a chat for CI replay
RecordCache stores turn results in a diskcache directory; replays skip the engine entirely. A miss raises unless RISHI_RECORD_CHAT=1 (or record=True). Keys hash model, briefing, tools, and conversation (KEY_VERSION bumps invalidate all recordings). CachedChat wraps Chat for docs/CI. Transient errors (timeouts, rate limits) are never recorded.
CachedChat
def CachedChat(
model:NoneType=None, # anything `Chat` takes; part of the key
path:NoneType=None, # the diskcache directory; None -> `CHAT_CACHE`
record:NoneType=None, # let a miss reach a real model; None -> $RISHI_RECORD_CHAT
sp:str='', # system prompt, part of the key
tools:NoneType=None, # tool names are part of the key; the real chat gets the tools themselves
**kw
):A Chat whose replies are recorded to disk and replayed on a second ask; a replay builds no engine.
RecordCache
def RecordCache(
path:NoneType=None, # the diskcache directory; None -> `CHAT_CACHE`
record:NoneType=None, # let a miss run for real; None -> the environment
env:str='RISHI_RECORD_CHAT', # what says a miss may run
version:NoneType=None, # part of every key; None -> `KEY_VERSION`
):Record what a call returned the first time and replay it every time after; the primitive under CachedChat.
is_transient
def is_transient(
e
):Is e a failure of the moment rather than of the ask? Those are not worth remembering.
Installing the skill
mv_skill_md() copies bundled skill.md into standard agent skill directories (.claude/skills/rishi/, .agents/skills/rishi/). dry_run=True prints paths only.
mv_skill_md
def mv_skill_md(
dry_run:bool=True, dir:NoneType=None
):Copy the bundled skill.md into .claude and .agents skill dirs so a harness can load the rishi skill.
repo_root
def repo_root()->Path:Root of the current git repository, or None if not in one.