lucaspetti commited on
Commit
7d87f88
·
0 Parent(s):

First initial commit

Browse files
Files changed (3) hide show
  1. README.md +49 -0
  2. app.py +360 -0
  3. requirements.txt +3 -0
README.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LFM2.5 Tool Use
3
+ emoji: 🛠️
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: 5.0.0
8
+ app_file: app.py
9
+ pinned: false
10
+ tags:
11
+ - tool-use
12
+ - liquid
13
+ - lfm2.5
14
+ - cpu
15
+ ---
16
+
17
+ # LFM2.5-1.2B-Thinking Tool-Calling Demo
18
+
19
+ A CPU-only Hugging Face Space that runs LiquidAI's [LFM2.5-1.2B-Thinking](https://huggingface.co/LiquidAI/LFM2.5-1.2B-Thinking) **in-process with `transformers`** and shows it performing tool (function) calling in a Gradio chat UI.
20
+
21
+ The model can call two mocked tools:
22
+
23
+ - `web_search(query)` — returns canned search results
24
+ - `send_email(to, subject, body)` — pretends to send an email
25
+
26
+ ## How it works
27
+
28
+ LFM2.5 emits tool calls in its native *Pythonic* format, wrapped in special tokens:
29
+
30
+ ```
31
+ <|tool_call_start|>[web_search(query="liquid ai lfm")]<|tool_call_end|>
32
+ ```
33
+
34
+ `app.py` parses that with the `ast` module, executes the matching tool, feeds the JSON result back as a `tool`-role message, and lets the model produce a final answer — looping up to 5 turns. Tokens stream into the UI as they generate.
35
+
36
+ > Note: the LFM2.5-1.2B chat template has a known bug where a structured `tool_calls` field is dropped on re-render, which breaks multi-turn tool calling. To avoid it we store the raw assistant text (special tokens intact) in the conversation history instead of relying on `tool_calls`.
37
+
38
+ ## Why CPU / float32
39
+
40
+ The free HF Space is CPU-only. We load the model in `float32` (the native fast path on CPU; the bf16 checkpoint upcasts cleanly) — ~4.7 GB, well within the 16 GB limit. CPU inference is slow, so be patient with each turn.
41
+
42
+ ## Run locally
43
+
44
+ ```bash
45
+ pip install -r requirements.txt
46
+ python app.py
47
+ ```
48
+
49
+ Adapted from the original Ollama-based `main.py`. Tool-calling reference: [Liquid docs](https://docs.liquid.ai/lfm/key-concepts/tool-use).
app.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LFM2.5-1.2B-Thinking tool-calling demo, packaged as a Hugging Face Space (CPU).
3
+
4
+ This is a CPU-only rewrite of `main.py`. The original talked to a local Ollama
5
+ server (`http://ubuntu.local:11434/v1`) and let Ollama parse the OpenAI-style
6
+ `tools` field for it. On a HF Space there is no Ollama, so we load the model
7
+ in-process with `transformers` and do the tool-call parsing ourselves.
8
+
9
+ LFM2.5's native tool format is *Pythonic*: the model emits
10
+
11
+ <|tool_call_start|>[web_search(query="liquid ai lfm")]<|tool_call_end|>
12
+
13
+ i.e. a Python list of function calls wrapped in special tokens. We parse that
14
+ with the `ast` module, execute the matching mock tool, feed the JSON result back
15
+ as a `tool`-role message, and let the model produce a final answer.
16
+
17
+ Reference: https://docs.liquid.ai/lfm/key-concepts/tool-use
18
+ https://huggingface.co/LiquidAI/LFM2.5-1.2B-Thinking
19
+ """
20
+
21
+ import ast
22
+ import inspect
23
+ import json
24
+ import os
25
+ import re
26
+ import threading
27
+
28
+ import gradio as gr
29
+ import torch
30
+ from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
31
+
32
+ MODEL_ID = "LiquidAI/LFM2.5-1.2B-Thinking"
33
+ MAX_NEW_TOKENS = 512
34
+ MAX_ITERATIONS = 5
35
+
36
+ # Native LFM2.5 tool-call delimiters.
37
+ TOOL_CALL_START = "<|tool_call_start|>"
38
+ TOOL_CALL_END = "<|tool_call_end|>"
39
+
40
+ # ----------------------------------------------------------------------------
41
+ # Tools (mocked, same behaviour as main.py)
42
+ # ----------------------------------------------------------------------------
43
+
44
+
45
+ def web_search(query: str) -> list[dict]:
46
+ """Mock web search; returns canned results regardless of the query."""
47
+ return [
48
+ {
49
+ "title": "Top result for: " + query,
50
+ "url": "https://example.com/search?q=" + query.replace(" ", "+"),
51
+ "snippet": f"A plausible-looking excerpt relevant to '{query}'.",
52
+ },
53
+ {
54
+ "title": "Secondary result for: " + query,
55
+ "url": "https://example.org/search?q=" + query.replace(" ", "+"),
56
+ "snippet": f"Another excerpt that touches on '{query}' from a different angle.",
57
+ },
58
+ ]
59
+
60
+
61
+ def send_email(to: str, subject: str, body: str) -> dict:
62
+ """Mock email sender; in real life this would talk to an SMTP server."""
63
+ print(f"\n--- drafting email ---\nTo: {to}\nSubject: {subject}\n{body}\n--- end ---")
64
+ return {
65
+ "status": "sent",
66
+ "to": to,
67
+ "subject": subject,
68
+ "message_id": "mock-0001",
69
+ }
70
+
71
+
72
+ # Registry of tools the model can call. Maps name -> callable.
73
+ TOOL_FUNCTIONS = {
74
+ "web_search": web_search,
75
+ "send_email": send_email,
76
+ }
77
+
78
+
79
+ def build_tools() -> list[dict]:
80
+ """Tool schema in LFM2.5's native (flat) format — what the model was
81
+ trained on. Dropped the OpenAI `{"type":"function","function":{...}}`
82
+ wrapper that `main.py` used for Ollama."""
83
+ return [
84
+ {
85
+ "name": "web_search",
86
+ "description": "Search the web for up-to-date information on a topic",
87
+ "parameters": {
88
+ "type": "object",
89
+ "properties": {
90
+ "query": {
91
+ "type": "string",
92
+ "description": "The search query",
93
+ }
94
+ },
95
+ "required": ["query"],
96
+ },
97
+ },
98
+ {
99
+ "name": "send_email",
100
+ "description": "Send an email to a recipient",
101
+ "parameters": {
102
+ "type": "object",
103
+ "properties": {
104
+ "to": {"type": "string", "description": "Recipient email address"},
105
+ "subject": {"type": "string", "description": "Email subject line"},
106
+ "body": {"type": "string", "description": "Email body content"},
107
+ },
108
+ "required": ["to", "subject", "body"],
109
+ },
110
+ },
111
+ ]
112
+
113
+
114
+ def system_prompt() -> str:
115
+ return (
116
+ "You have tools available. Always use web_search to fetch facts; never answer "
117
+ "from memory. When the user asks you to, use send_email to send emails.\n"
118
+ "To call a tool, emit a Python list of function calls between the special "
119
+ "tokens, e.g. "
120
+ f"{TOOL_CALL_START}[web_search(query='liquid ai lfm')]{TOOL_CALL_END}.\n"
121
+ "After receiving tool results, summarize them and answer the user.\n\n"
122
+ f"List of tools: {json.dumps(build_tools())}"
123
+ )
124
+
125
+
126
+ # ----------------------------------------------------------------------------
127
+ # Tool-call parsing (LFM2.5 emits Pythonic calls)
128
+ # ----------------------------------------------------------------------------
129
+
130
+
131
+ def parse_tool_calls(text: str) -> list[dict]:
132
+ """Extract tool calls from raw model output.
133
+
134
+ The model writes `<|tool_call_start|>[fn(a='1', b='2')]<|tool_call_end|>`,
135
+ possibly with several calls in one list. We parse the list with `ast`
136
+ (not `ast.literal_eval`, since a bare function call isn't a literal) and
137
+ walk the AST for each call's name + keyword arguments.
138
+ """
139
+ calls = []
140
+ pattern = re.escape(TOOL_CALL_START) + r"(.*?)" + re.escape(TOOL_CALL_END)
141
+ for match in re.finditer(pattern, text, re.DOTALL):
142
+ body = match.group(1).strip()
143
+ try:
144
+ tree = ast.parse(body, mode="eval").body
145
+ except SyntaxError:
146
+ continue
147
+ if isinstance(tree, ast.Call):
148
+ call_nodes = [tree]
149
+ elif isinstance(tree, ast.List):
150
+ call_nodes = [e for e in tree.elts if isinstance(e, ast.Call)]
151
+ else:
152
+ continue
153
+
154
+ for node in call_nodes:
155
+ if not isinstance(node.func, ast.Name):
156
+ continue
157
+ name = node.func.id
158
+ arguments: dict = {}
159
+ # Keyword arguments, e.g. query="..."
160
+ for kw in node.keywords:
161
+ if kw.arg is None:
162
+ continue
163
+ try:
164
+ arguments[kw.arg] = ast.literal_eval(kw.value)
165
+ except (ValueError, SyntaxError):
166
+ arguments[kw.arg] = ast.unparse(kw.value)
167
+ # Positional arguments -> map onto parameter names by signature.
168
+ fn = TOOL_FUNCTIONS.get(name)
169
+ if fn is not None:
170
+ params = list(inspect.signature(fn).parameters)
171
+ for i, arg in enumerate(node.args):
172
+ if i < len(params):
173
+ try:
174
+ arguments[params[i]] = ast.literal_eval(arg)
175
+ except (ValueError, SyntaxError):
176
+ arguments[params[i]] = ast.unparse(arg)
177
+ calls.append({"name": name, "arguments": arguments})
178
+ return calls
179
+
180
+
181
+ def execute_tool(name: str, arguments: dict) -> str:
182
+ """Run one parsed tool call, leniently — mirrors main.py's handling.
183
+
184
+ Small models hallucinate extra params; we drop anything the function
185
+ doesn't accept, and ask for a retry if a required param ends up missing.
186
+ """
187
+ fn = TOOL_FUNCTIONS.get(name)
188
+ if fn is None:
189
+ return json.dumps({"error": f"Unknown tool: {name}"})
190
+
191
+ accepted = set(inspect.signature(fn).parameters)
192
+ valid = {k: v for k, v in arguments.items() if k in accepted}
193
+ dropped = sorted(set(arguments) - accepted)
194
+ if dropped:
195
+ print(f" (dropping hallucinated params: {dropped})")
196
+
197
+ required = {
198
+ p
199
+ for p, param in inspect.signature(fn).parameters.items()
200
+ if param.default is inspect.Parameter.empty
201
+ }
202
+ missing = sorted(required - set(valid))
203
+ if missing:
204
+ return json.dumps(
205
+ {"error": f"Missing required parameter(s) {missing} for tool '{name}'"}
206
+ )
207
+
208
+ result = fn(**valid)
209
+ return json.dumps(result)
210
+
211
+
212
+ # ----------------------------------------------------------------------------
213
+ # Model loading (CPU, in-process)
214
+ # ----------------------------------------------------------------------------
215
+
216
+ print(f"Loading {MODEL_ID} on CPU (float32)…")
217
+ # float32 is the native fast path on CPU; the bf16 checkpoint upcasts fine and
218
+ # fits comfortably in the free 16 GB CPU Space (~4.7 GB).
219
+ torch.set_num_threads(min(8, os.cpu_count() or 4))
220
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
221
+ model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32)
222
+ model.to("cpu")
223
+ model.eval()
224
+ print("Model ready.")
225
+
226
+
227
+ def generate_stream(messages: list[dict]):
228
+ """Run one generation turn, streaming tokens. Returns (streamer, thread)."""
229
+ input_ids = tokenizer.apply_chat_template(
230
+ messages,
231
+ add_generation_prompt=True,
232
+ return_tensors="pt",
233
+ tokenize=True,
234
+ ).to(model.device)
235
+ streamer = TextIteratorStreamer(
236
+ tokenizer, skip_special_tokens=False, skip_prompt=True
237
+ )
238
+ thread = threading.Thread(
239
+ target=model.generate,
240
+ args=(input_ids,),
241
+ kwargs={
242
+ "do_sample": True,
243
+ "temperature": 0.05,
244
+ "top_k": 50,
245
+ "repetition_penalty": 1.05,
246
+ "max_new_tokens": MAX_NEW_TOKENS,
247
+ "streamer": streamer,
248
+ },
249
+ )
250
+ thread.start()
251
+ return streamer, thread
252
+
253
+
254
+ # ----------------------------------------------------------------------------
255
+ # Display helpers
256
+ # ----------------------------------------------------------------------------
257
+
258
+
259
+ def render_display(raw: str) -> str:
260
+ """Turn raw model output (with special tokens) into readable markdown."""
261
+ s = raw.replace("<|im_start|>", "").replace("<|im_end|>", "")
262
+ s = s.replace(TOOL_CALL_START, "\n\n🔧 **Tool call:**\n```python\n")
263
+ s = s.replace(TOOL_CALL_END, "\n```\n")
264
+ return s.strip()
265
+
266
+
267
+ # ----------------------------------------------------------------------------
268
+ # Gradio app
269
+ # ----------------------------------------------------------------------------
270
+
271
+ EXAMPLES = [
272
+ "Find the latest news about LiquidAI's LFM models, then email a short "
273
+ "summary with the URLs to alice@example.com.",
274
+ "Search the web for what C. elegans is and explain it.",
275
+ ]
276
+
277
+
278
+ def respond(user_msg: str, history: list[dict]):
279
+ """Generator driving the tool-calling loop, streaming into the chatbot."""
280
+ messages = [{"role": "system", "content": system_prompt()}] + list(history)
281
+ messages.append({"role": "user", "content": user_msg})
282
+
283
+ chatbot = [{"role": "user", "content": user_msg}]
284
+ yield chatbot, messages[1:], ""
285
+
286
+ for turn in range(1, MAX_ITERATIONS + 1):
287
+ chatbot.append({"role": "assistant", "content": ""}) # streaming placeholder
288
+ streamer, thread = generate_stream(messages)
289
+ raw = ""
290
+ for chunk in streamer:
291
+ raw += chunk
292
+ chatbot[-1] = {"role": "assistant", "content": render_display(raw)}
293
+ yield chatbot, messages[1:], ""
294
+ thread.join()
295
+
296
+ # Keep the raw assistant turn (special tokens intact) for the next
297
+ # round — the LFM2.5 1.2B chat template drops a structured `tool_calls`
298
+ # field on re-render (known bug), so we must store the literal text.
299
+ messages.append({"role": "assistant", "content": raw.replace("<|im_end|>", "").rstrip()})
300
+
301
+ tool_calls = parse_tool_calls(raw)
302
+ if not tool_calls:
303
+ # No tool call => final answer; show it cleaned up and stop.
304
+ chatbot[-1] = {"role": "assistant", "content": render_display(raw)}
305
+ yield chatbot, messages[1:], ""
306
+ return
307
+
308
+ # Execute every requested tool and feed results back as tool messages.
309
+ for call in tool_calls:
310
+ result = execute_tool(call["name"], call["arguments"])
311
+ messages.append({"role": "tool", "content": result})
312
+ chatbot.append(
313
+ {
314
+ "role": "assistant",
315
+ "content": f"🔧 **{call['name']}** result:\n```json\n{result}\n```",
316
+ }
317
+ )
318
+ yield chatbot, messages[1:], ""
319
+
320
+ chatbot.append(
321
+ {
322
+ "role": "assistant",
323
+ "content": f"_Reached the {MAX_ITERATIONS}-iteration cap without a final answer._",
324
+ }
325
+ )
326
+ yield chatbot, messages[1:], ""
327
+
328
+
329
+ with gr.Blocks(title="LFM2.5 Tool Use", theme=gr.themes.Soft()) as demo:
330
+ gr.Markdown(
331
+ "# 🛠️ LFM2.5-1.2B-Thinking — Tool Calling (CPU)\n"
332
+ "Runs **in-process on CPU**. The model can call `web_search` and "
333
+ "`send_email` (both mocked). Watch it emit tool calls, execute them, "
334
+ "and produce a final answer. Be patient — CPU inference is slow."
335
+ )
336
+ chatbot = gr.Chatbot(type="messages", height=520, label="Conversation")
337
+ with gr.Row():
338
+ txt = gr.Textbox(
339
+ placeholder="Ask me to search the web or send an email…",
340
+ scale=8,
341
+ show_label=False,
342
+ autofocus=True,
343
+ )
344
+ btn = gr.Button("Send", variant="primary")
345
+ clr = gr.Button("Clear")
346
+
347
+ history_state = gr.State([])
348
+
349
+ btn.click(respond, [txt, history_state], [chatbot, history_state, txt])
350
+ txt.submit(respond, [txt, history_state], [chatbot, history_state, txt])
351
+ clr.click(
352
+ lambda: ([], [], ""),
353
+ outputs=[chatbot, history_state, txt],
354
+ )
355
+
356
+ gr.Examples(examples=EXAMPLES, inputs=txt)
357
+
358
+
359
+ if __name__ == "__main__":
360
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ transformers>=5.2.0
2
+ torch
3
+ gradio>=5.0