"""Lisette Core"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.

# %% auto #0
__all__ = ['sonn45', 'opus45', 'opus46', 'tool_dtls_tag', 're_tools', 'token_dtls_tag', 're_token', 'effort', 'tc_res_sysp',
           'patch_litellm', 'remove_cache_ckpts', 'contents', 'stop_reason', 'mk_msg', 'fmt2hist', 'mk_msgs',
           'stream_with_complete', 'lite_mk_func', 'ToolResponse', 'structured', 'cite_footnote', 'cite_footnotes',
           'mk_stream_chunk', 'Chat', 'add_warning', 'random_tool_id', 'mk_tc', 'mk_tc_req', 'mk_tc_result',
           'mk_tc_results', 'astream_with_complete', 'AsyncChat', 'mk_tr_details', 'fmt_usage', 'StreamFormatter',
           'AsyncStreamFormatter', 'display_stream', 'adisplay_stream']

# %% ../nbs/00_core.ipynb #82380377
import asyncio, base64, json, litellm, mimetypes, random, string, ast
from typing import Optional,Callable
from html import escape
from litellm import (acompletion, completion, stream_chunk_builder, Message,
                     ModelResponse, ModelResponseStream, get_model_info, register_model, Usage)
from litellm.utils import function_to_dict, StreamingChoices, Delta, ChatCompletionMessageToolCall, Function, Choices
from toolslm.funccall import mk_ns, call_func, call_func_async, get_schema
from fastcore.utils import *
from fastcore.meta import delegates
from fastcore import imghdr
from dataclasses import dataclass
from litellm.exceptions import ContextWindowExceededError

# %% ../nbs/00_core.ipynb #2c28e8ce
def patch_litellm(seed=0):
    "Patch litellm.ModelResponseBase such that `id` and `created` are fixed."
    from litellm.types.utils import ModelResponseBase, ChatCompletionMessageToolCall
    from uuid import UUID
    from base64 import b64encode
    if seed is not None: random.seed(seed) # ensures random ids like tool call ids are deterministic
    
    @patch
    def __init__(self: ModelResponseBase, id=None, created=None, *args, **kwargs): 
        self._orig___init__(id='chatcmpl-xxx', created=1000000000, *args, **kwargs)

    @patch
    def __setattr__(self: ModelResponseBase, name, value):
        if name == 'id': value = 'chatcmpl-xxx'
        elif name == 'created': value = 1000000000
        self._orig___setattr__(name, value)

    def _unqid():
        res = b64encode(UUID(int=random.getrandbits(128), version=4).bytes)
        return '_' + res.decode().rstrip('=').translate(str.maketrans('+/', '__'))  # both to underscore for srvtoolu_ pattern

    @patch
    def __init__(self: ChatCompletionMessageToolCall, function=None, id=None, type="function", **kwargs):
        # we keep the tool call prefix if it exists, this is needed for example to handle srvtoolu_ correctly.
        id = id.split('_')[0]+_unqid() if id and '_' in id else id
        self._orig___init__(function=function, id=id, type=type, **kwargs)

# %% ../nbs/00_core.ipynb #d61cf441
@patch
def _repr_markdown_(self: litellm.ModelResponse):
    message = self.choices[0].message
    content = ''
    if mc:=message.content: content += mc[0]['text'] if isinstance(mc,list) else mc
    if message.tool_calls:
        tool_calls = [f"\n\n🔧 {nested_idx(tc,'function','name')}({nested_idx(tc,'function','arguments')})\n" for tc in message.tool_calls]
        content += "\n".join(tool_calls)
    for img in getattr(message, 'images', []): content += f"\n\n![generated image]({nested_idx(img, 'image_url', 'url')})"
    if not content: content = str(message)
    details = [
        f"id: `{self.id}`",
        f"model: `{self.model}`",
        f"finish_reason: `{self.choices[0].finish_reason}`"
    ]
    if hasattr(self, 'usage') and self.usage: details.append(f"usage: `{self.usage}`")
    det_str = '\n- '.join(details)
    
    return f"""{content}

<details>

- {det_str}

</details>"""

# %% ../nbs/00_core.ipynb #fac6cee5
sonn45 = "claude-sonnet-4-5"
opus45 = "claude-opus-4-5"
opus46 = "claude-opus-4-6"

# %% ../nbs/00_core.ipynb #d4c8b8f2
def _bytes2content(data):
    "Convert bytes to litellm content dict (image, pdf, audio, video)"
    mtype = detect_mime(data)
    if not mtype: raise ValueError(f'Data must be a supported file type, got {data[:10]}')
    encoded = base64.b64encode(data).decode("utf-8")    
    if mtype.startswith('image/'): return {'type': 'image_url', 'image_url': f'data:{mtype};base64,{encoded}'}
    return {'type': 'file', 'file': {'file_data': f'data:{mtype};base64,{encoded}'}}

# %% ../nbs/00_core.ipynb #ef65f38b
def _add_cache_control(msg,          # LiteLLM formatted msg
                       ttl=None):    # Cache TTL: '5m' (default) or '1h'
    "cache `msg` with default time-to-live (ttl) of 5minutes ('5m'), but can be set to '1h'."
    cc = {"type": "ephemeral"} | ({"ttl": ttl} if ttl else {})
    if tcs := msg.get('tool_calls'):
        tcs[-1]['cache_control'] = cc
        return msg
    if not (content := msg.get("content")): return msg
    if isinstance(content, str): msg["content"] = [{"type": "text", "text": content}]
    if msg["content"] and msg["content"][-1].get("text"):
        msg["content"][-1]["cache_control"] = cc
    return msg

def _has_cache(msg):
    "Check if msg has cache_control set"
    if tcs := msg.get('tool_calls'): return 'cache_control' in tcs[-1]
    content = msg.get("content")
    return content and isinstance(content, list) and 'cache_control' in content[-1]

def remove_cache_ckpts(msg):
    "remove cache checkpoints and return msg."
    if not _has_cache(msg): return msg
    if tcs := msg.get('tool_calls'):
        tc = tcs[-1]
        if isinstance(tc, dict): tc.pop('cache_control', None)
        elif isinstance(tc, ChatCompletionMessageToolCall): delattr(tc, 'cache_control')
    elif msg.get('content'): msg["content"][-1].pop('cache_control', None)
    return msg

def _mk_content(o):
    if isinstance(o, str): return {'type':'text','text':o.strip() or '.'}
    elif isinstance(o,bytes): return _bytes2content(o)
    return o

def contents(r):
    "Get message object from response `r`."
    if not r.choices: return ''
    return r.choices[0].message

def stop_reason(r):
    if not r.choices: return 'unk'
    return r.choices[0].finish_reason

# %% ../nbs/00_core.ipynb #ecb67a0e
def mk_msg(
    content,      # Content: str, bytes (image), list of mixed content, or dict w 'role' and 'content' fields
    role="user",  # Message role if content isn't already a dict/Message
    cache=False,  # Enable Anthropic caching
    ttl=None      # Cache TTL: '5m' (default) or '1h'
):
    "Create a LiteLLM compatible message."
    if isinstance(content, dict) or isinstance(content, Message): return content
    if isinstance(content, ModelResponse): return contents(content)
    if isinstance(content, list) and len(content) == 1 and isinstance(content[0], str): c = content[0]
    elif isinstance(content, list): c = [_mk_content(o) for o in content]
    else: c = content
    msg = {"role": role, "content": c}
    return _add_cache_control(msg, ttl=ttl) if cache else msg

# %% ../nbs/00_core.ipynb #8886f917
tool_dtls_tag = "<details class='tool-usage-details'>"
re_tools = re.compile(fr"^({tool_dtls_tag}\n*(?:<summary>.*?</summary>\n*)?\n*```json\n+(.*?)\n+```\n+</details>)",
                      flags=re.DOTALL|re.MULTILINE)
token_dtls_tag = "<details class='token-usage-details'>"
re_token = re.compile(fr"^{re.escape(token_dtls_tag)}<summary>.*?</summary>\n*\n*`Usage\(.*?\)`\n*\n*</details>\n?",
                      flags=re.DOTALL|re.MULTILINE)

# %% ../nbs/00_core.ipynb #303951a2
def _extract_tool(text:str)->tuple[dict,dict]:
    "Extract tool call and results from <details> block"
    try: d = json.loads(text.strip())
    except: return
    call = d['call']
    func = call['function']
    tc = ChatCompletionMessageToolCall(Function(dumps(call['arguments']),func), d['id'])
    tr = {'role': 'tool','tool_call_id': d['id'],'name': func, 'content': d['result']}
    return tc,tr

def fmt2hist(outp:str)->list:
    "Transform a formatted output into a LiteLLM compatible history"
    lm,hist = Message(),[]
    if token_dtls_tag in outp: outp = re_token.sub('', outp)
    if tool_dtls_tag not in outp: return [outp]
    spt = re_tools.split(outp.strip())
    for is_last,(txt,_,tooljson) in loop_last(chunked(spt,3,pad=True)):
        if is_last and not (txt or '').strip() and not tooljson: continue
        txt = txt.strip() if tooljson or txt.strip() else '.'
        hist.append(lm:=Message(txt))
        if tooljson:
            if tcr := _extract_tool(tooljson):
                if not hist: hist.append(lm) # if LLM calls a tool without talking
                lm.tool_calls = lm.tool_calls+[tcr[0]] if lm.tool_calls else [tcr[0]] 
                hist.append(tcr[1])
    if hist and isinstance(hist[-1], dict): hist.append(Message('.'))
    return hist

# %% ../nbs/00_core.ipynb #02cb84da
def _apply_cache_idxs(msgs, cache_idxs=[-1], ttl=None):
    'Add cache control to idxs after filtering tools'
    ms = [o for o in msgs if o['role']!='tool']
    for i in cache_idxs:
        try: _add_cache_control(ms[i], ttl)
        except IndexError: continue

# %% ../nbs/00_core.ipynb #9b326d7d
def mk_msgs(
    msgs,                   # List of messages (each: str, bytes, list, or dict w 'role' and 'content' fields)
    cache=False,            # Enable Anthropic caching
    cache_idxs=[-1],        # Cache breakpoint idxs
    ttl=None,               # Cache TTL: '5m' (default) or '1h'
):
    "Create a list of LiteLLM compatible messages."
    if not msgs: return []
    if not isinstance(msgs, list): msgs = [msgs]
    res,role = [],'user'
    msgs = L(msgs).map(fmt2hist).concat()
    for m in msgs:
        res.append(msg:=remove_cache_ckpts(mk_msg(m, role=role)))
        role = 'assistant' if msg['role'] in ('user','function', 'tool') else 'user'
    if cache: _apply_cache_idxs(res, cache_idxs, ttl)
    return res

# %% ../nbs/00_core.ipynb #9ad6fc2c
def stream_with_complete(gen, postproc=noop):
    "Extend streaming response chunks with the complete response"
    chunks = []
    for chunk in gen:
        chunks.append(chunk)
        yield chunk
    postproc(chunks)
    return stream_chunk_builder(chunks)

# %% ../nbs/00_core.ipynb #4301402e
def lite_mk_func(f):
    if isinstance(f, dict): return f
    return {'type':'function', 'function':get_schema(f, pname='parameters')}

# %% ../nbs/00_core.ipynb #9ac2035b
@dataclass
class ToolResponse:
    content: list[str,str]

# %% ../nbs/00_core.ipynb #a0a7019f
def _prep_tool_res(res, tcid):
    "Prepend tool call ID text block to result"
    id_block = {'type': 'text', 'text': f'[tool_call_id: {tcid}]'}
    if isinstance(res, list): return [id_block, *res]
    return [id_block, {'type': 'text', 'text': str(res)}]

# %% ../nbs/00_core.ipynb #cda90f73
_re_tool_ref = re.compile(r'^\$`([^`]+)`$')

def _resolve_tool_refs(args_str, tc_res):
    "Resolve $`tool_call_id` references in tool arguments"
    d = json.loads(args_str)
    if tc_res is None: return d
    for k,v in d.items():
        if isinstance(v, str) and (m := _re_tool_ref.match(v)):
            tcid = m.group(1)
            d[k] = tc_res[tcid] if tcid in tc_res else f"Tool result '{tcid}' not found!"
    return d

# %% ../nbs/00_core.ipynb #530f1e6a
def _try_eval(o):
    if isinstance(o, str):
        try: return ast.literal_eval(o)
        except: return o
    return o

# %% ../nbs/00_core.ipynb #238576e5
def _mk_tool_result(tc, res, tc_res=None, tc_res_eval=False):
    "Unwrap `ToolResponse`, store result if needed, and format tool result message"
    is_tr = isinstance(res, ToolResponse)
    res = res.content if is_tr else res
    if tc_res is not None: tc_res[tc.id] = _try_eval(res) if tc_res_eval else res
    content = _prep_tool_res(res, tc.id) if tc_res is not None else (res if is_tr else str(res))
    return {"tool_call_id": tc.id, "role": "tool", "name": tc.function.name, "content": content}

# %% ../nbs/00_core.ipynb #c4d81d05
def _lite_call_func(tc, tool_schemas, ns, raise_on_err=True, tc_res=None, tc_res_eval=False):
    "Call tool function synchronously and return formatted result"
    fn, valid = tc.function.name, {nested_idx(o,'function','name') for o in tool_schemas or []}
    if fn not in valid: res = f"Tool not defined in tool_schemas: {fn}"
    else:
        try: fargs = _resolve_tool_refs(tc.function.arguments, tc_res)
        except json.JSONDecodeError: res = f"Failed to parse function arguments: {tc.function.arguments}"
        else: res = call_func(fn, fargs, ns=ns, raise_on_err=False)
    return _mk_tool_result(tc, res, tc_res, tc_res_eval)

# %% ../nbs/00_core.ipynb #4688cf77
@delegates(completion)
def structured(
    m:str,          # LiteLLM model string
    msgs:list,      # List of messages 
    tool:Callable,  # Tool to be used for creating the structured output (class, dataclass or Pydantic, function, etc)
    **kwargs):
    "Return the value of the tool call (generally used for structured outputs)"
    t = lite_mk_func(tool)
    r = completion(m, msgs, tools=[t], tool_choice=t, **kwargs)
    args = json.loads(r.choices[0].message.tool_calls[0].function.arguments)
    return tool(**args)

# %% ../nbs/00_core.ipynb #6530af43
def _has_search(m):
    i = get_model_info(m)
    return bool(i.get('search_context_cost_per_query') or i.get('supports_web_search'))

# %% ../nbs/00_core.ipynb #fc341e7e
def cite_footnote(msg):
    if not (delta:=nested_idx(msg, 'choices', 0, 'delta')): return
    if citation:= nested_idx(delta, 'provider_specific_fields', 'citation'):
        title = citation['title'].replace('"', '\\"')
        delta.content = f'[*]({citation["url"]} "{title}") '
        
def cite_footnotes(stream_list):
    "Add markdown footnote citations to stream deltas"
    for msg in stream_list: cite_footnote(msg)

# %% ../nbs/00_core.ipynb #a636d732
effort = AttrDict({o[0]:o for o in ('low','medium','high')})

# %% ../nbs/00_core.ipynb #715e9a83
def mk_stream_chunk(**kwargs): return ModelResponseStream([StreamingChoices(delta=Delta(**kwargs))])
def _mk_prefill(pf): return mk_stream_chunk(content=pf, role='assistant')


# %% ../nbs/00_core.ipynb #f3ac31b4
def _trunc_str(s, mx=2000, replace="<TRUNCATED>"):
    "Truncate `s` to `mx` chars max, adding `replace` if truncated"
    s = str(s).strip()
    if len(s)<=mx: return s
    s = s[:mx]
    ss = s.split(' ')
    if len(ss[-1])>50: ss[-1] = ss[-1][:5]
    s = ' '.join(ss)
    return s+replace

# %% ../nbs/00_core.ipynb #e18e226c
_final_prompt = dict(role="user", content="You have used all your tool calls for this turn. Please summarize your findings. If you did not complete your goal, tell the user what further work is needed. You may use tools again on the next user message.")

_cwe_msg = "ContextWindowExceededError: Do no more tool calls and complete your response now. Inform user that you ran out of context and explain what the cause was. This is the response to this tool call, truncated if needed: "

# %% ../nbs/00_core.ipynb #9adb06b9
tc_res_sysp = """You can reference previous tool call results using $`tool_call_id` syntax.
For example, if a tool call returns result with id 'toolu_abc123', you can use it in a subsequent call:
{"content": "$`toolu_abc123`"}
This is useful when chaining tools, e.g., reading data with one tool and passing it to another."""

# %% ../nbs/00_core.ipynb #a9ece479
class Chat:
    def __init__(
        self,
        model:str,                # LiteLLM compatible model name 
        sp='',                    # System prompt
        temp=0,                   # Temperature
        search=False,             # Search (l,m,h), if model supports it
        tools:list=None,          # Add tools
        hist:list=None,           # Chat history
        ns:Optional[dict]=None,   # Custom namespace for tool calling 
        cache=False,              # Anthropic prompt caching
        cache_idxs:list=[-1],     # Anthropic cache breakpoint idxs, use `0` for sys prompt if provided
        ttl=None,                 # Anthropic prompt caching ttl
        api_base=None,            # API base URL for custom providers
        api_key=None,             # API key for custom providers
        extra_headers=None,       # Extra HTTP headers for custom providers
        tc_refs=False,            # Enable tool call result references
        tc_res_eval=False,        # literal_eval tool results before storing in tc_res
    ):
        "LiteLLM chat client."
        self.model = model
        self.tc_res = {} if tc_refs else None
        if tc_refs: sp = f"{sp}\n\n{tc_res_sysp}" if sp else tc_res_sysp
        hist,tools = mk_msgs(hist,cache,cache_idxs,ttl),listify(tools)
        if ns is None and tools: ns = mk_ns(tools)
        elif ns is None: ns = globals()
        self.tool_schemas = [lite_mk_func(t) for t in tools] if tools else None
        store_attr()
    
    def _prep_msg(self, msg=None, prefill=None):
        "Prepare the messages list for the API call"
        sp = [{"role": "system", "content": self.sp}] if self.sp else []
        if sp:
            if 0 in self.cache_idxs: sp[0] = _add_cache_control(sp[0])
            cache_idxs = L(self.cache_idxs).filter().map(lambda o: o-1 if o>0 else o)
        else:
            cache_idxs = self.cache_idxs
        if msg: self.hist = self.hist+[msg]
        self.hist = mk_msgs(self.hist, self.cache and 'claude' in self.model, cache_idxs, self.ttl)
        pf = [{"role":"assistant","content":prefill}] if prefill else []
        return sp + self.hist + pf

# %% ../nbs/00_core.ipynb #d356b12a
def _filter_srvtools(tcs): return L(tcs).filter(lambda o: not o.id.startswith('srvtoolu_')) if tcs else None

# %% ../nbs/00_core.ipynb #d6644bc0
def add_warning(r, msg):
    if contents(r).content: r.choices[0].message.content += f"\n\n<warning>{msg}</warning>"
    else: r.choices[0].message.content = f"<warning>{msg}</warning>"

# %% ../nbs/00_core.ipynb #13476eca
def _handle_stop_reason(res):
    "Returns (action, warning_msg) - action is 'warning', 'pause', or None"
    sr = stop_reason(res)
    if sr == 'length': return 'warning', 'Response was cut off at token limit.'
    if sr == 'refusal': return 'warning', 'AI was unable to process this request'
    if sr == 'pause_turn': return 'retry', None
    return None, None


# %% ../nbs/00_core.ipynb #4f58a3c9
@patch
def _prep_call(self:Chat, prefill, search, max_tokens, kwargs):
    "Prepare model info, prefill, search, and provider kwargs for a completion call"
    try: model_info = get_model_info(self.model)
    except Exception:
        register_model({self.model: {}})
        model_info = get_model_info(self.model)
    if max_tokens is None: max_tokens = model_info.get('max_output_tokens')
    if not model_info.get("supports_assistant_prefill"): prefill = None
    if _has_search(self.model) and (s:=ifnone(search,self.search)): kwargs['web_search_options'] = {"search_context_size": effort[s]}
    else: kwargs.pop('web_search_options', None)
    if self.api_base: kwargs['api_base'] = self.api_base
    if self.api_key: kwargs['api_key'] = self.api_key
    if self.extra_headers: kwargs['extra_headers'] = self.extra_headers
    return prefill, max_tokens

# %% ../nbs/00_core.ipynb #fa528e85
@patch
def _call(self:Chat, msg=None, prefill=None, temp=None, think=None, search=None, stream=False,
        max_steps=2, step=1, final_prompt=None, tool_choice=None, max_tokens=None, **kwargs):
    "Internal method that always yields responses"
    if step>max_steps+1: return
    prefill, max_tokens = self._prep_call(prefill, search, max_tokens, kwargs)
    res = completion(
        model=self.model, messages=self._prep_msg(msg, prefill), stream=stream, max_tokens=int(max_tokens),
        tools=self.tool_schemas, reasoning_effort = effort.get(think), tool_choice=tool_choice,
        # temperature is not supported when reasoning
        temperature=None if think else ifnone(temp,self.temp),
        caching=self.cache and 'claude' not in self.model,
        **kwargs)
    if stream:
        if prefill: yield _mk_prefill(prefill)
        res = yield from stream_with_complete(res,postproc=cite_footnotes)
    m = contents(res)
    if prefill: m.content = prefill + m.content
    self.hist.append(m)
    action, msg = _handle_stop_reason(res)
    if action == 'warning': add_warning(res, msg)
    elif action == 'retry':
        yield from self._call(
            None, prefill, temp, think, search, stream, max_steps, step,
            final_prompt, tool_choice, **kwargs)
        self.hist.pop(-2) # rm incomplete srvtoolu_
        return
    yield res
    if tcs := _filter_srvtools(m.tool_calls):
        _f = partial(_lite_call_func, tool_schemas=self.tool_schemas, ns=self.ns, tc_res=self.tc_res, tc_res_eval=self.tc_res_eval)
        workers = min(len(tcs),8) if len(tcs)>1 else 0 # not parallel if 1 call
        tool_results=list(parallel(_f, tcs, threadpool=True, n_workers=workers))
        self.hist+=tool_results
        for r in tool_results: yield r
        if step>=max_steps: prompt,tool_choice,search = final_prompt,'none',False
        else: prompt = None
        try: yield from self._call(
            prompt, prefill, temp, think, search, stream, max_steps, step+1,
            final_prompt, tool_choice, **kwargs)
        except ContextWindowExceededError:
            for t in tool_results:
                if len(t['content'])>1000: t['content'] = _cwe_msg + _trunc_str(t['content'], mx=1000)
            yield from self._call(None, prefill, temp, think, search, stream, max_steps, max_steps, final_prompt, 'none', **kwargs)

# %% ../nbs/00_core.ipynb #266f3d5d
@patch
@delegates(Chat._call)
def __call__(self:Chat,
             msg=None,          # Message str, or list of multiple message parts
             prefill=None,      # Prefill AI response if model supports it
             temp=None,         # Override temp set on chat initialization
             think=None,        # Thinking (l,m,h)
             search=None,       # Override search set on chat initialization (l,m,h)
             stream=False,      # Stream results
             max_steps=2, # Maximum number of tool calls
             final_prompt=_final_prompt, # Final prompt when tool calls have ran out 
             return_all=False,  # Returns all intermediate ModelResponses if not streaming and has tool calls
             **kwargs):
    "Main call method - handles streaming vs non-streaming"
    result_gen = self._call(msg, prefill, temp, think, search, stream, max_steps, 1, final_prompt, **kwargs)     
    if stream: return result_gen              # streaming
    elif return_all: return list(result_gen)  # toolloop behavior
    else: return last(result_gen)             # normal chat behavior

# %% ../nbs/00_core.ipynb #2e9247ba
@patch
def print_hist(self:Chat):
    "Print each message on a different line"
    for r in self.hist: print(r, end='\n\n')

# %% ../nbs/00_core.ipynb #a37a77b6
def random_tool_id():
    "Generate a random tool ID with 'toolu_' prefix"
    random_part = ''.join(random.choices(string.ascii_letters + string.digits, k=25))
    return f'toolu_{random_part}'

# %% ../nbs/00_core.ipynb #e00e88b4
def mk_tc(func, args, tcid=None, idx=1):
    if not tcid: tcid = random_tool_id()
    return {'index': idx, 'function': {'arguments': args, 'name': func}, 'id': tcid, 'type': 'function'}

# %% ../nbs/00_core.ipynb #00cebbbb
def mk_tc_req(content, tcs):
    msg = Message(content=content, role='assistant', tool_calls=tcs, function_call=None)
    msg.tool_calls = [{**dict(tc), 'function': dict(tc['function'])} for tc in msg.tool_calls]
    return msg

# %% ../nbs/00_core.ipynb #59e69d43
def mk_tc_result(tc, result): return {'tool_call_id': tc['id'], 'role': 'tool', 'name': tc['function']['name'], 'content': result}

# %% ../nbs/00_core.ipynb #e5d8e695
def mk_tc_results(tcq, results): return [mk_tc_result(a,b) for a,b in zip(tcq.tool_calls, results)]

# %% ../nbs/00_core.ipynb #d934ac41
async def _alite_call_func(tc, tool_schemas, ns, raise_on_err=True, tc_res=None, tc_res_eval=False):
    "Call tool function asynchronously and return formatted result"
    fn, valid = tc.function.name, {nested_idx(o,'function','name') for o in tool_schemas or []}
    if fn not in valid: res = f"Tool not defined in tool_schemas: {fn}"
    else:
        try: fargs = _resolve_tool_refs(tc.function.arguments, tc_res)
        except json.JSONDecodeError: res = f"Failed to parse function arguments: {tc.function.arguments}"
        else: res = await call_func_async(fn, fargs, ns=ns, raise_on_err=False)
    return _mk_tool_result(tc, res, tc_res, tc_res_eval)

# %% ../nbs/00_core.ipynb #13cf1122
@asave_iter
async def astream_with_complete(self, agen, postproc=noop):
    chunks = []
    async for chunk in agen:
        chunks.append(chunk)
        postproc(chunk)
        yield chunk
    self.value = stream_chunk_builder(chunks)

# %% ../nbs/00_core.ipynb #f354e37b
class AsyncChat(Chat):
    async def _call(self, msg=None, prefill=None, temp=None, think=None, search=None, stream=False, max_steps=2, step=1,
            final_prompt=None, tool_choice=None, max_tokens=None, **kwargs):
        if step>max_steps+1: return
        prefill, max_tokens = self._prep_call(prefill, search, max_tokens, kwargs)
        res = await acompletion(model=self.model, messages=self._prep_msg(msg, prefill), stream=stream,
                         tools=self.tool_schemas, reasoning_effort=effort.get(think), tool_choice=tool_choice, max_tokens=int(max_tokens),
                         # temperature is not supported when reasoning
                         temperature=None if think else ifnone(temp,self.temp), 
                         caching=self.cache and 'claude' not in self.model,
                         **kwargs)
        if stream:
            if prefill: yield _mk_prefill(prefill)
            res = astream_with_complete(res,postproc=cite_footnote)
            async for chunk in res: yield chunk
            res = res.value
        m=contents(res)
        if prefill: m.content = prefill + m.content
        self.hist.append(m)
        action, msg = _handle_stop_reason(res)
        if action == 'warning': add_warning(res, msg)
        elif action == 'retry':
            async for result in self._call(
                None, prefill, temp, think, search, stream, max_steps, step,
                final_prompt, tool_choice, **kwargs): yield result
            self.hist.pop(-2) # rm incomplete srvtoolu_
            return
        yield res

        if tcs := _filter_srvtools(m.tool_calls):
            tool_results = await asyncio.gather(*[
                _alite_call_func(tc, self.tool_schemas, self.ns, tc_res=self.tc_res, tc_res_eval=self.tc_res_eval) for tc in tcs])
            for r in tool_results: yield r
            self.hist+=tool_results
            if step>=max_steps-1: prompt,tool_choice,search = final_prompt,'none',False
            else: prompt = None
            try:
                async for result in self._call(
                    prompt, prefill, temp, think, search, stream, max_steps, step+1,
                    final_prompt, tool_choice=tool_choice, **kwargs): yield result
            except ContextWindowExceededError:
                for t in tool_results:
                    if len(t['content'])>1000: t['content'] = _cwe_msg + _trunc_str(t['content'], mx=1000)
                async for result in self._call(
                    prompt, prefill, temp, think, search, stream, max_steps, step+1,
                    final_prompt, tool_choice='none', **kwargs): yield result

# %% ../nbs/00_core.ipynb #9bc01816
@patch
@delegates(Chat._call)
async def __call__(
    self:AsyncChat,
    msg=None,          # Message str, or list of multiple message parts
    prefill=None,      # Prefill AI response if model supports it
    temp=None,         # Override temp set on chat initialization
    think=None,        # Thinking (l,m,h)
    search=None,       # Override search set on chat initialization (l,m,h)
    stream=False,      # Stream results
    max_steps=2, # Maximum number of tool calls
    final_prompt=_final_prompt, # Final prompt when tool calls have ran out 
    return_all=False,  # Returns all intermediate ModelResponses if not streaming and has tool calls
    **kwargs
):
    result_gen = self._call(msg, prefill, temp, think, search, stream, max_steps, 1, final_prompt, **kwargs)
    if stream or return_all: return result_gen
    async for res in result_gen: pass
    return res # normal chat behavior only return last msg

# %% ../nbs/00_core.ipynb #049f141f
def _trunc_param(v, mx=50):
    "Truncate and escape param value for display"
    tp = _trunc_str(str(v).replace('`', r'\`'), mx=mx, replace='…')
    try: return ast.literal_eval(tp)
    except Exception: return repr(tp).replace('\\\\', '\\')

def mk_tr_details(tr, tc, mx=2000):
    "Create <details> block for tool call as JSON"
    args = {k:_trunc_str(v, mx=mx) for k,v in json.loads(tc.function.arguments).items()}
    res = {'id':tr['tool_call_id'], 
           'call':{'function': tc.function.name, 'arguments': args},
           'result':_trunc_str(tr.get('content'), mx=mx),}
    params = ', '.join(f"{k}={_trunc_param(v)}" for k,v in args.items())
    summ = f"<summary>{tc.function.name}({params})</summary>"
    return f"\n\n{tool_dtls_tag}\n{summ}\n\n```json\n{dumps(res, indent=2)}\n```\n\n</details>\n\n"

# %% ../nbs/00_core.ipynb #c49b2749
def fmt_usage(u):
    "Format usage stats with cache hit rate as lead metric."
    prompt,comp,total = u.prompt_tokens or 0, u.completion_tokens or 0, u.total_tokens or 0
    cached = nested_idx(u,'prompt_tokens_details','cached_tokens') or 0
    cache_new = nested_idx(u,'prompt_tokens_details','cache_creation_tokens') or 0
    reasoning = nested_idx(u,'completion_tokens_details','reasoning_tokens') or 0
    hit = f"{100*cached/prompt:.1f}%" if prompt else "N/A"
    cache_info = f" (+{cached:,} cached, {cache_new:,} new)" if cached or cache_new else ""
    reason_info = f" (reasoning {reasoning:,})" if reasoning else ""
    return f"Cache hit: {hit} | Tokens: total={total:,} input={prompt:,}{cache_info} output={comp:,}{reason_info}"

# %% ../nbs/00_core.ipynb #89c788df
class StreamFormatter:
    def __init__(self, include_usage=False, mx=2000, debug=False, showthink=False):
        self.outp,self.tcs = '',{}
        store_attr()
    
    def format_item(self, o):
        "Format a single item from the response stream."
        res = ''
        if self.debug: print(o)
        if isinstance(o, ModelResponseStream):
            d = o.choices[0].delta
            if nested_idx(d, 'reasoning_content') and d['reasoning_content']!='{"text": ""}':
                if self.showthink: res += str(nested_idx(d, 'reasoning_content'))
                res+= '🧠' if not self.outp or self.outp[-1]=='🧠' else '\n\n🧠' # gemini can interleave reasoning
            elif self.outp and self.outp[-1] == '🧠': res+= '\n\n'
            if c:=d.content: # gemini has text content in last reasoning chunk
                res+=f"\n\n{c}" if res and res[-1] == '🧠' else c
            for img in getattr(d, 'images', []): res += f"\n\n![generated image]({nested_idx(img, 'image_url', 'url')})\n\n"
        elif isinstance(o, ModelResponse):
            if self.include_usage: res += f"\n{token_dtls_tag}<summary>{fmt_usage(o.usage)}</summary>\n\n`{o.usage}`\n\n</details>\n"
            if c:=getattr(contents(o),'tool_calls',None):
                self.tcs = {tc.id:tc for tc in c}
        elif isinstance(o, dict) and 'tool_call_id' in o:
            res += mk_tr_details(o, self.tcs.pop(o['tool_call_id']), mx=self.mx)
        self.outp+=res
        return res
    
    def format_stream(self, rs):
        "Format the response stream for markdown display."
        for o in rs: yield self.format_item(o)

# %% ../nbs/00_core.ipynb #7a6199ff
class AsyncStreamFormatter(StreamFormatter):
    async def format_stream(self, rs):
        "Format the response stream for markdown display."
        async for o in rs: yield self.format_item(o)

# %% ../nbs/00_core.ipynb #75ee8bce
def display_stream(rs):
    "Use IPython.display to markdown display the response stream."
    try: from IPython.display import display, Markdown
    except ModuleNotFoundError: raise ModuleNotFoundError("This function requires ipython. Please run `pip install ipython` to use.")
    fmt = StreamFormatter()
    md = ''
    for o in fmt.format_stream(rs): 
        md+=o
        display(Markdown(md),clear=True)
    return fmt

# %% ../nbs/00_core.ipynb #845df8b9
async def adisplay_stream(rs):
    "Use IPython.display to markdown display the response stream."
    try: from IPython.display import display, Markdown
    except ModuleNotFoundError: raise ModuleNotFoundError("This function requires ipython. Please run `pip install ipython` to use.")
    fmt = AsyncStreamFormatter()
    md = ''
    async for o in fmt.format_stream(rs): 
        md+=o
        display(Markdown(md),clear=True)
    return fmt
