from fastcore.test import test_eq, test_fail, test_closeremote
Chat API with API keys in the environment (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, …).
Messages
fastllm’s canonical message is a Msg(role, content=[Part, ...]), where a Part is typed: text, thinking, tool_use, tool_result, input_image/input_audio (whose payload is a data URL in part.text). rishi’s canonical history is OpenAI-shaped dicts. Neither is richer than the other in practice, so this is a straight two-way mapping - and it is where media fidelity across a backend hop actually gets decided, so images and audio are carried through as data URLs rather than collapsed to a placeholder.
Note thinking round-trips properly here: rishi keeps it in channels.thought, fastllm keeps it as a thinking Part, and each converts to the other.
to_hist
def to_hist(
m
):A fastllm Msg -> canonical rishi history dicts (a tool Msg can hold several results).
to_msg
def to_msg(
m
):One canonical rishi history dict -> a fastllm Msg.
# a round trip through fastllm's Msg keeps text, thinking, tool calls and tool results intact
h = [{'role': 'user', 'content': 'what is 2+2?'},
{'role': 'assistant', 'content': 'let me add', 'channels': {'thought': 'hmm'},
'tool_calls': [ToolCall('add', {'a': 2, 'b': 2}, id='c1')]},
{'role': 'tool', 'tool_call_id': 'c1', 'name': 'add', 'content': '4'},
{'role': 'assistant', 'content': 'it is 4'}]
msgs = [to_msg(m) for m in h]
test_eq([m.role for m in msgs], ['user', 'assistant', 'tool', 'assistant'])
back = [x for m in msgs for x in to_hist(m)]
test_eq(back[0], {'role': 'user', 'content': 'what is 2+2?'})
test_eq(back[1]['channels'], {'thought': 'hmm'})
test_eq(back[1]['tool_calls'][0].name, 'add')
test_eq(back[1]['tool_calls'][0].arguments, {'a': 2, 'b': 2})
test_eq(back[2], {'role': 'tool', 'tool_call_id': 'c1', 'name': 'add', 'content': '4'})
test_eq(back[3], {'role': 'assistant', 'content': 'it is 4'})
# media survives the hop as a data URL, rather than being collapsed to a placeholder
png = b'\x89PNG\r\n\x1a\n' + b'0' * 8
um = mk_oai_msg([png, 'what is this?'])
m = to_msg(um)
test_eq([p.type for p in m.content], ['input_image', 'text'])
assert m.content[0].text.startswith('data:image/png;base64,')
rt = to_hist(m)[0]
test_eq(rt['content'][1]['image_url']['url'], m.content[0].text)
# audio too
wav = mk_oai_msg([b'RIFF0000WAVE', 'transcribe'])
am = to_msg(wav)
test_eq([p.type for p in am.content], ['input_audio', 'text'])
test_eq(to_hist(am)[0]['content'][1]['input_audio']['format'], 'wav')
# a server-side tool call keeps its flag across the hop
sm = to_msg({'role': 'assistant', 'content': '', 'tool_calls': [ToolCall('web_search', {}, id='s1', server=True)]})
assert to_hist(sm)[0]['tool_calls'][0].serverResponses and usage
norm_completion maps fastllm completions to rishi Resp. UsageStats.cached_tokens is populated when the provider reports prompt caching.
norm_completion
def norm_completion(
comp
):fastllm Completion -> rishi Resp, with <tool_call> tags read out of the text as core.norm_resp does.
norm_usage
def norm_usage(
u, model:NoneType=None
):fastllm Usage -> rishi UsageStats.
RemoteChat
Async wire via run_coro / sync_iter. Hosted-only passthrough: tool_choice, reasoning_effort, and tool_mode='tags'|'native'. Provider-run tools return server=True and are recorded, not executed locally.
RemoteChat
def RemoteChat(
model:NoneType=None, runtime:NoneType=None, model_path:NoneType=None, api_key:NoneType=None,
base_url:NoneType=None, vendor_name:NoneType=None, api_name:NoneType=None, sp:str='', messages:NoneType=None,
tools:NoneType=None, ctx_limit:NoneType=None, approve:NoneType=None, tool_max_len:NoneType=None,
max_steps:int=10, parallel_tools:bool=False, max_parallel_tools:NoneType=None,
final_prompt:str="You've reached the tool-call budget for this turn. Stop calling tools and answer with what you already have.",
tool_choice:NoneType=None, reasoning_effort:NoneType=None, temp:NoneType=None, max_output_tokens:int=4096,
retries:int=2, comp_kw:NoneType=None, cbs:NoneType=None, default_cbs:bool=True,
tool_mode:str='native', # 'native' sends schemas on the wire; 'tags' puts them in the system prompt
):Chat against a hosted model through fastllm - the same rishi.core.Chat API as the local backends.
Against a real API
Chat('gpt-5.1'), Chat('anthropic/claude-sonnet-4-5'), or runtime='remote' — routing only; you need a vendor key.
from fastllm.acomplete import acomplete # undo the test patch above
chat = Chat('gpt-4.1', sp='You are concise.')
test_eq(chat.runtime, 'remote')
r = chat('Reply with exactly: pong')
assert 'pong' in resp_text(r).lower()
print(chat.use)total=22|in=20|out=2|turns=1|model=gpt-4.1
display_stream(Chat('gpt-5.1')('Write two sentences about the monsoon.', stream=True))The monsoon is a seasonal wind system that brings heavy rainfall to regions such as South Asia, Southeast Asia, and parts of Africa. It is crucial for agriculture and water resources but can also cause severe flooding and landslides.
'The monsoon is a seasonal wind system that brings heavy rainfall to regions such as South Asia, Southeast Asia, and parts of Africa. It is crucial for agriculture and water resources but can also cause severe flooding and landslides.'
th = Chat('gpt-5.1', reasoning_effort='high', max_output_tokens=2048)
r = th('A bat and ball cost $1.10, the bat is $1 more than the ball. How much is the ball?')
print(thought(r)[:400]); print('---'); print(resp_text(r))
---
Let the price of the ball be \(x\) dollars.
Then the bat costs \(x + 1\) dollars.
Total cost:
\[
x + (x + 1) = 1.10
\]
\[
2x + 1 = 1.10
\]
\[
2x = 0.10
\]
\[
x = 0.05
\]
The ball costs **5 cents**.
img = Path('images.jpeg').read_bytes()
print(resp_text(Chat('gpt-5.1')([img, 'What is in this image? One sentence.'])))A German Shepherd dog is standing on an outdoor path with its tongue out.
# the whole point: start local, finish hosted, with one history
from rishi.llama import LlamaChat, qwen3_17b
local = LlamaChat(qwen3_17b, n_ctx=4096)
local('My name is Karthik and my favourite number is 17. Remember both.')
hist = local.hist
local.close()
remote = Chat('gpt-5.1', messages=hist)
r = remote('What is my name and my favourite number?')
assert 'karthik' in resp_text(r).lower() and '17' in resp_text(r)
resp_text(r)/Users/71293/code/personal/orgs/ramabana/.venv/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
from .autonotebook import tqdm as notebook_tqdm
llama_context: n_ctx_seq (4096) < n_ctx_train (40960) -- the full capacity of the model will not be utilized
'Your name is Karthik, and your favourite number is 17.'