Apple SDK: small on-device LLMs

Overview

Run LFM2.5-230M / 350M, Qwen3-0.6B and Gemma 3 1B on device with the TheStage Apple SDK — the shipped packs, no fine-tune. At these sizes a phone can fill an expense form from a photographed receipt, add a calendar event from a chat thread, copy a parcel pickup code, route a share-sheet paste, and answer a live question through a tool. They are not open-domain chat models.

Every reply below was produced by the example project that ships with the SDK (examples/tutor_small_llms) on Apple Silicon, on the Neural Engine, with the packs published for SDK 1.4. You can rerun all of it with one command — see Run it yourself.

API reference: LLM (Language Model).

What you will learn

  • The one design rule that makes small packs reliable: the model copies what is printed or said; the app computes anything derived.

  • When to decode greedily and when to use the family’s sampling card — and why extraction and chat are different jobs.

  • How to pass few-shot examples as real chat turns.

  • Four share-sheet recipes on realistic dumps (a fiscal receipt with OCR noise, a chat thread, four carriers’ pickup notices), each run on all four packs.

  • Tool calling with DefaultTools — a live weather and time answer from a spoken question.

Which pack for which job

Scores from the runs in this tutorial — cases passed out of cases tried. ✅ all passed · ⚠️ most passed, the miss is named in the recipe · ❌ not this pack.

Job

LFM2.5-230M

LFM2.5-350M

Qwen3-0.6B

Gemma 3 1B

Expense JSON from a receipt

⚠️ 1/2

✅ 2/2

✅ 2/2

⚠️ 1/2

Calendar event from a chat thread

⚠️ 1/2

⚠️ 1/2

✅ 2/2

⚠️ 1/2

Parcel pickup code, 4 carriers

❌ 0/4

⚠️ 3/4

⚠️ 3/4

✅ 4/4

Share-sheet router

Tool call for a live fact

✅ 3/3

✅ 3/3

✅ 3/3

no tools

Open-domain Q&A, math, code

Qwen3-0.6B is the safe default for every job here. Gemma 3 1B has no tool support — passing tools: throws. Keep Qwen thinking off (enable_thinking = false) for all of these.

What you use from the SDK

SDK piece

Role in this tutorial

TheStageAI.shared

initialize(api_token:) once; downloads and caches packs.

TSLLM

The on-device model. One instance per pack.

LLMChatEngine

infer(messages:) — the few-shot example and the live input as real chat turns, one call, nothing stored.

LLMGenerationConfig

Greedy for extraction, the vendor card for tools.

Tool, DefaultTools

Tool calling: schema + execute; the SDK runs the loop.

LLMStreamEvent

text_delta is what you show or speak; tool_call is what you log.

What you implement in the app

You build

Why

One-job system prompts

Schema or labels in system_prompt; the worked example as user / assistant turns; the live dump last.

A normaliser

Turns what the model copied (14/O8/2026, tomorrow, 18:30) into a Date. Deterministic; unit-tested.

JSON / label validation

Parse, allow-list, fall back.

Optional router

Cheap classify first, then extract.

Where it is useful

Anywhere the input is already on the device — share sheet, keyboard, receipt photo OCR, mail, notes — and the value is privacy and latency, not world knowledge.

The one rule. The answer must be contained in the input, or fetched by a tool you registered. Transform, classify, extract, route — never recall. Ask “who won the 2010 World Cup” and a 230M–1B pack will guess.

The second rule, which this tutorial exists to teach. Ask the model to copy, not to compute. A 0.6B model asked to turn 14/O8/2026 into 2026-08-14 fails on every pack; asked to copy the printed string it succeeds on every pack, and ten lines of Swift fix the OCR and parse the date. Same for “tomorrow 18:30”: copy the words, resolve them against today in the app.

Requirement

Detail

OS

iOS / iPadOS 18+, Apple Silicon macOS 15+

Hardware

Physical Apple Silicon. The Simulator is not supported.

SDK

Apple SDK 1.4.0+

Models

TheStageAI/LFM2.5-230M, TheStageAI/LFM2.5-350M, TheStageAI/Qwen3-0.6B, TheStageAI/gemma-3-1b-it

Auth

API token from app.thestage.ai

Component-by-component build

Load a pack

import TheStageSDK

try await TheStageAI.shared.initialize(api_token: token)

let llm = try await TSLLM(
    engines_path: "TheStageAI/Qwen3-0.6B",
    max_context_size: 2048
)
let engine = LLMChatEngine(llm: llm)

Greedy for extraction, the vendor card for chat

Two different jobs, two configs.

Extraction (receipt, calendar, pickup code, router) wants the one right answer, the same every time. Decode greedily: temperature 0, no top-k / top-p filtering. This is what every recipe table below was produced with.

func greedy_config(_ llm: TSLLM, max_new_tokens: Int) -> LLMGenerationConfig {
    var config = llm.generation_defaults
    config.temperature = 0
    config.top_k = 0
    config.top_p = 1
    config.min_p = 0
    config.repetition_penalty = 1.0
    config.max_new_tokens = max_new_tokens
    config.enable_thinking = false
    return config
}

Chat and tools want natural phrasing. Use the sampling card each vendor publishes for its model — they differ, and a pack’s generation_defaults is not always the vendor’s card.

Family

temp

top_k

top_p

min_p

rep. penalty

LFM2.5

0.1

50

1.0

0.15

1.05

Qwen3 (non-thinking)

0.7

20

0.8

0

1.0

Gemma 3

1.0

64

0.95

0

1.0

enum PackFamily { case lfm, qwen, gemma }

func vendor_config(_ llm: TSLLM, family: PackFamily) -> LLMGenerationConfig {
    var config = llm.generation_defaults
    config.enable_thinking = false
    config.max_new_tokens = 256
    switch family {
    case .lfm:
        config.temperature = 0.1;  config.top_k = 50
        config.top_p = 1.0;        config.min_p = 0.15
        config.repetition_penalty = 1.05
    case .qwen:
        config.temperature = 0.7;  config.top_k = 20
        config.top_p = 0.8;        config.min_p = 0
        config.repetition_penalty = 1.0
    case .gemma:
        config.temperature = 1.0;  config.top_k = 64
        config.top_p = 0.95;       config.min_p = 0
        config.repetition_penalty = 1.0
    }
    return config
}

Few-shot as chat turns

The pack’s chat template already tags roles. Put the worked example in messages as a user / assistant pair, then the live input as the last user turn. Do not paste the example into the system prompt — the model then treats it as instructions, not as a prior reply.

let result = try await engine.infer(
    messages: [
        .user(example_input),
        .assistant(example_output),
        .user(live_input),
    ],
    system_prompt: system,
    tools: [],
    config: greedy_config(llm, max_new_tokens: 128)
)
// result.text is the reply; result.stop_reason == "max_new_tokens" means it was cut

The normaliser

The app-side half of every recipe. The model copies; this turns the copy into a value. It is plain Swift with unit checks — no model involved, so it never surprises you.

/// OCR on fiscal tickets swaps letters for digits inside numbers.
private func repair_ocr_digits(_ text: String) -> String {
    var out = ""
    for ch in text {
        switch ch {
        case "O", "o", "Q", "D": out.append("0")
        case "l", "I", "|":      out.append("1")
        case "S":                out.append("5")
        case "B":                out.append("8")
        default:                 out.append(ch)
        }
    }
    return out
}

/// `14/O8/2026`, `03.11.25`, `02-09-2026` → a day. Day-first is a locale
/// fact the app knows; the model does not have to infer it.
func normalize_printed_date(_ printed: String) -> DateComponents? {
    let day_field = repair_ocr_digits(printed)
        .split(separator: " ").first.map(String.init) ?? ""
    let parts = day_field.split(whereSeparator: { "/.-".contains($0) }).map(String.init)
    guard parts.count == 3,
          let day = Int(parts[0]), let month = Int(parts[1]), var year = Int(parts[2])
    else { return nil }
    if parts[2].count == 2 { year += 2000 }
    guard (1...31).contains(day), (1...12).contains(month), (2000...2100).contains(year)
    else { return nil }
    var c = DateComponents(); c.year = year; c.month = month; c.day = day
    let cal = Calendar(identifier: .gregorian)
    guard let d = cal.date(from: c), cal.component(.day, from: d) == day else { return nil }
    return c
}

/// Drop the `[dd/mm/yyyy, HH:mm:ss] ` prefixes of a chat export so the
/// model never mistakes a send time for the meeting time.
func strip_export_prefixes(_ export: String) -> String {
    export.split(separator: "\n", omittingEmptySubsequences: false).map { line -> String in
        var s = Substring(line)
        if s.hasPrefix("["), let close = s.firstIndex(of: "]") {
            s = s[s.index(after: close)...]
            while s.hasPrefix(" ") { s = s.dropFirst() }
        }
        return String(s)
    }.joined(separator: "\n")
}

/// `14/08`, `tomorrow`, `in 3 days`, `friday` + `18:30` → a concrete Date.
/// Returns nil for any phrase it does not know — the app then shows the
/// copied words and asks the user to confirm. It never guesses.
func resolve_said_datetime(day_said: String, start_said: String,
                           today: Date, calendar: Calendar) -> Date? {
    let t = start_said.split(separator: ":").map(String.init)
    guard t.count == 2, let hour = Int(t[0]), let minute = Int(t[1]) else { return nil }
    let key = day_said.lowercased().trimmingCharacters(in: .whitespaces)
    var day: Date?
    if key == "today"    { day = calendar.startOfDay(for: today) }
    else if key == "tomorrow" {
        day = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: today))
    } else if let rel = relative_days(key) {            // "in 3 days", "in 2 weeks"
        day = calendar.date(byAdding: rel.unit, value: rel.count,
                            to: calendar.startOfDay(for: today))
    } else if let weekday = weekday_index(key) {
        var c = DateComponents(); c.weekday = weekday
        day = calendar.nextDate(after: calendar.startOfDay(for: today).addingTimeInterval(-1),
                                matching: c, matchingPolicy: .nextTime)
    } else {
        let p = key.split(whereSeparator: { "/.-".contains($0) }).map(String.init)
        if p.count >= 2, let d = Int(p[0]), let m = Int(p[1]) {
            var c = DateComponents(); c.day = d; c.month = m
            c.year = p.count > 2 ? Int(p[2]).map { $0 < 100 ? $0 + 2000 : $0 }
                                 : calendar.component(.year, from: today)
            day = calendar.date(from: c)
        }
    }
    guard let base = day else { return nil }
    return calendar.date(bySettingHour: hour, minute: minute, second: 0, of: base)
}

weekday_index maps monday / mon … to 1…7; relative_days parses in N days | weeks | months. The full file, with the check suite, is in the example project.

Where the model’s job ends. The model’s contribution is the hard, fuzzy part: finding the day phrase and the time in four messy bubbles, and noticing that “18:00?” was revised to “18:30”. Turning “tomorrow” into a date is arithmetic, and the app owns arithmetic. The vocabulary above is deliberately small and English-only — “next Friday”, “day after tomorrow”, “domani” return nil — and nil means show the copied words and ask, never guess. The ✅ marks in the calendar table mean the model copied the right phrase and the right time; the ISO stamps are what the app made of them.

Expense JSON from a receipt

Camera OCR of an Italian documento commerciale. The OCR noise is real: Cappuccin0, 14/O8/2026. The model copies the printed date character for character; normalize_printed_date turns it into a day.

let system = """
Extract one expense record from an Italian documento commerciale \
(Agenzia delle Entrate RT / Fatture e Corrispettivi layout). Camera \
OCR is noisy: 0/O swaps, extra headers, VAT lines, card last-4, \
document numbers.

Reply with this JSON object only — no markdown, no extra keys:
{"merchant": string, "total": number, "currency": "EUR", "date_printed": string}

merchant — the shop's legal or trade name (the company line, often \
above DOCUMENTO COMMERCIALE). Not P.I. / P.IVA, not DOC, not the \
street, not a line-item name, not the card last-4.
total — the amount actually paid for the purchase. Dot decimal. Not \
di cui IVA, not one line item, not the cash tendered, not the change given.
currency — EUR on Italian fiscal tickets.
date_printed — copy the date exactly as printed on the ticket, \
character for character. Do not reformat it, do not fix OCR, do not \
guess a year.
"""

let example_input = """
AP0THEKE AM BAHNH0F
LINIENSTR. 4O  BERLIN
Ibuprofen 400mg N20        8,49
SUMME EUR                  8,49
EC ****8812
03.11.25  17:22
BON-NR 004821
"""
let example_output =
    "{\"merchant\":\"APOTHEKE AM BAHNHOF\",\"total\":8.49,\"currency\":\"EUR\",\"date_printed\":\"03.11.25\"}"

let ticket = """
STARBUCKS COFFEE ITALY S.R.L.
ROMA TERMINI - VIA GIOBERTI
P.I. 01234560966

DOCUMENTO COMMERCIALE
di vendita o prestazione

DESCRIZIONE              IVA   Prezzo(€)
Cappuccin0            2  10%     7,00
Cornetto              1  10%     1,80
Subtotale                        8,80
TOTALE COMPLESSIVO               8,80
di cui IVA                       0,80
Pagamento elettronico            8,80
CARTA ****4521

14/O8/2026  08:41
DOC N. 0001234
"""

let result = try await engine.infer(
    messages: [.user(example_input), .assistant(example_output), .user(ticket)],
    system_prompt: system, tools: [],
    config: greedy_config(llm, max_new_tokens: 128)
)

struct Expense: Decodable {
    let merchant: String; let total: Double; let currency: String; let date_printed: String
}
let expense = try JSONDecoder().decode(Expense.self, from: Data(result.text.utf8))
guard ticket.contains(expense.date_printed),                 // it must be a copy
      let day = normalize_printed_date(expense.date_printed)  // 2026-08-14
else { throw ExtractionError.notFoundInInput }

Two tickets: the Starbucks one above (want 8.80, 14/O8/2026) and a tobacconist’s with a cash-and-change layout (want 7.70, 02-09-2026).

OK

Pack

Ticket 1 (Starbucks)

Ticket 2 (Tabaccheria)

⚠️

LFM2.5-230M

✅ copies 14/O8/2026 → 2026-08-14

❌ right values, but extra keys — decode fails

LFM2.5-350M

✅ copies 14/O8/2026 → 2026-08-14

✅ copies 02-09-2026 → 2026-09-02

Qwen3-0.6B

✅ copies 14/O8/2026 → 2026-08-14

✅ copies 02-09-2026 → 2026-09-02

⚠️

Gemma 3 1B

❌ writes 14/08/2026 — fixed the OCR, not a copy; guard rejects

✅ copies 02-09-2026 → 2026-09-02

The LFM and Qwen packs copy the printed date faithfully — OCR O included — and the app fixes it; Gemma “helpfully” corrects the OCR, which the verbatim guard has to reject (relax the guard to compare after repair_ocr_digits if you want to accept that). Asked instead for YYYY-MM-DD, no pack in this fleet gets the Starbucks date right. For LFM2.5-230M, keep JSONDecoder strict and treat a decode failure as “ask a bigger pack”.

Calendar event from a chat thread

A WhatsApp export, [dd/mm/yyyy, HH:mm:ss] Sender: body. The app strips the bubble prefixes first (strip_export_prefixes) so a send time can never be mistaken for the meeting time, and the model copies what was said"14/08", "13:00" — not an ISO datetime.

let system = """
Read a chat thread and report the meeting they agreed on. Do not \
compute dates and do not convert anything.

Reply with this JSON object only — no markdown, no extra keys:
{"title": string, "day_said": string, "start_said": string, \
"end_said": string or null, "location": string or null}

title — short Title Case name: the activity plus who. Not small talk \
from earlier lines. Not a sentence.
day_said — copy the day exactly as the thread says it: "14/08", \
"tomorrow", "friday". Do not turn it into a date.
start_said — the agreed start time as they wrote it, 24-hour clock. \
If they revised it, use the time they settled on.
end_said — the time someone said they have to leave, or null if nobody said.
location — the place they named, or null.
"""

let example_input = """
Ana Kovac: coffee tue 19/08 10:30 at the station? i leave 11:00
You: yes
"""
let example_output =
    "{\"title\":\"Coffee With Ana\",\"day_said\":\"19/08\",\"start_said\":\"10:30\",\"end_said\":\"11:00\",\"location\":\"the station\"}"

let export = """
[13/08/2026, 12:04:11] Marco Rossi: yo u around fri
[13/08/2026, 12:05:03] You: yeah till sat
[13/08/2026, 12:07:44] Marco Rossi: lunch 14/08 13:00 at the termini place? i gotta run 14:30 so dont be late
[13/08/2026, 12:08:02] You: ok see you there
"""

let result = try await engine.infer(
    messages: [.user(example_input), .assistant(example_output),
               .user(strip_export_prefixes(export))],
    system_prompt: system, tools: [],
    config: greedy_config(llm, max_new_tokens: 128)
)

struct Meeting: Decodable {
    let title: String; let day_said: String; let start_said: String
    let end_said: String?; let location: String?
}
let meeting = try JSONDecoder().decode(Meeting.self, from: Data(result.text.utf8))
guard let start = resolve_said_datetime(day_said: meeting.day_said,
                                        start_said: meeting.start_said,
                                        today: today, calendar: calendar)
else {
    // Unknown phrase: show "\(meeting.day_said) \(meeting.start_said)" and ask
    return
}
// start == 2026-08-14 13:00

Two threads: the export above (want 14/08, 13:00, 14:30) and a harder one with no few-shot where the day and the time are in different bubbles and the time is revised — “climbing gym tomorrow?” … “18:00?” … “make it 18:30” — using a prompt that asks for one "when" string (tomorrow 18:30).

OK

Pack

Thread 1 (export, few-shot)

Thread 2 (split bubbles, no few-shot)

⚠️

LFM2.5-230M

14/08 · 13:0014:30

❌ tool-call markup, not JSON

⚠️

LFM2.5-350M

✅ same → 2026-08-14 13:00

tomorrow — drops the clock

Qwen3-0.6B

✅ same → 2026-08-14 13:00

tomorrow 18:30 → 2026-08-14 18:30

⚠️

Gemma 3 1B

✅ same → 2026-08-14 13:00

❌ fenced JSON; keeps 18:00, not the revised 18:30

With the prefixes stripped and copy-only fields, every pack gets the straightforward thread. Only Qwen3 glues a day from one bubble to a revised time from another without an example; give the LFM packs a few-shot for that shape or ask the user to confirm.

Parcel pickup code

Share the carrier’s SMS, get the code and nothing else. One prompt, one German few-shot, four carriers the model has not seen: Amazon Hub (EN), InPost (PL), Royal Mail (EN, a 5-digit PIN), Bring (NO, a hyphenated code).

let system = """
Read a parcel pickup notification and return the pickup code only. \
One token — no sentence, no quotes.

The pickup code is the short number the recipient types at the locker \
or shows at the counter. It is not the tracking or consignment number, \
not the order number, not the opening hours, not a phone number. Copy \
it exactly, including any hyphen.
"""
let example_input = "Ihre Sendung 00340434123456789016 liegt in der DHL Packstation 139, "
    + "Berlin Hbf. Ihr Abholcode: 4482. Geöffnet 06:00-22:00."
let example_output = "4482"

let sms = """
Your package with 1 item is ready to be picked up from Amazon Hub Locker - Roma Termini (Via Giolitti).

Your pickup code is 482917

Use this code to pick up your package. Locker hours: 06:00-23:00.
Order #204-8471932-5510237
"""

let result = try await engine.infer(
    messages: [.user(example_input), .assistant(example_output), .user(sms)],
    system_prompt: system, tools: [],
    config: greedy_config(llm, max_new_tokens: 16)
)
let code = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard sms.contains(code) else { throw ExtractionError.notFoundInInput }   // never trust a code that is not in the input

OK

Pack

Amazon 482917

InPost 730114

Royal Mail 61529

Bring 4471-88

LFM2.5-230M

4482 (the example)

❌ sentence

❌ sentence

❌ code + words

⚠️

LFM2.5-350M

4471 (drops -88)

⚠️

Qwen3-0.6B

447188 (drops the hyphen)

Gemma 3 1B

4471-88

The 230M pack parrots the few-shot; use 350M, Qwen3 or Gemma. The hyphenated Norwegian code is the one shape 350M and Qwen3 mangle — only Gemma keeps the hyphen — and the sms.contains guard catches it, so you show the SMS instead of a wrong code.

Share-sheet router

Classify the paste before you pick a recipe. Two few-shots, a Royal Mail SMS as the live input; the right label is locker.

let system = """
Classify one share-sheet paste into exactly one label. Reply with \
that label only — no sentence, no punctuation.

receipt — a fiscal receipt / documento commerciale / Kassenbon (shop name, IVA, TOTALE).
event — a chat thread that agrees a meeting time.
locker — a carrier or parcel pickup notice (PIN / pickup code / Abholcode / kod odbioru).
other — none of the above.

Classify the document type, not a polite wrapper.
"""

let result = try await engine.infer(
    messages: [
        .user("DOCUMENTO COMMERCIALE\nTOTALE COMPLESSIVO    12,50\nP.I. 01234560966"),
        .assistant("receipt"),
        .user("[12/08/2026, 18:02:11] Ana Kovac: coffee thu 10:30 at the station?\n[12/08/2026, 18:03:01] You: yes"),
        .assistant("event"),
        .user(royal_mail_sms),
    ],
    system_prompt: system, tools: [],
    config: greedy_config(llm, max_new_tokens: 16)
)
let label = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
let allowed: Set<String> = ["receipt", "event", "locker", "other"]
guard allowed.contains(label) else { /* treat as "other" */ }

OK

Pack

Output

LFM2.5-230M

event

LFM2.5-350M

locker

Qwen3-0.6B

locker

Gemma 3 1B

other

Tool calling — a live fact

The one job where the answer is not in the input: current weather, current time. Register tools; the SDK teaches the model the pack’s call format, runs execute, and continues the reply on the same stream. This is the chat job — use the vendor card, not greedy.

The built-in DefaultTools.get_weather and get_local_time work as-is. What improves the hit rate is a fuller description of when to use the tool:

var weather = DefaultTools.get_weather
weather.definition.description = """
    Current outdoor conditions and air temperature in °C at one named \
    place. This is live data: use it whenever a correct answer depends \
    on what the weather is like there now. Not historical or multi-day \
    forecast data. Argument: the place the user named, in the form they \
    said it. Returns a short plain-text summary to paraphrase; do not \
    read it out verbatim.
    """
let tools = [weather, DefaultTools.get_local_time, DefaultTools.web_search]

for ask in ["What's the weather in Paris right now?",
            "What time is it in Tokyo?",
            "Thanks, that's all for now."] {
    for await event in try llm.infer_stream(
        prompt: ask,
        tools: tools,
        system_prompt: DefaultTools.voice_system_prompt,
        config: vendor_config(llm, family: .qwen)
    ) {
        switch event {
        case .text_delta(let text): print(text, terminator: "")   // show or speak
        case .tool_call(let call):  print("[tool_call] \(call.name)")
        default: break
        }
    }
}

Expected: get_weather then a spoken line with the temperature; get_local_time then the time; no tool on the thank-you.

OK

Pack

Weather in Paris

Time in Tokyo

“Thanks, that’s all”

LFM2.5-230M

✅ call, then 25.5 °C

✅ call, then 20:26

✅ no tool

LFM2.5-350M

✅ call, then 25.5 °C

✅ call, then 20:25

✅ no tool

Qwen3-0.6B

✅ call, then 25.5 °C

✅ call, then 20:27

✅ no tool

Gemma 3 1B

no tools

no tools

All three tool-capable packs call the right tool on a direct ask and stay quiet on small talk. The LFM packs sometimes restate the year in the spoken time (a “2023” or “2024” for 2026) — read the year from the tool result in the app if you show it.

Run it yourself

The example project reproduces every table above:

git clone https://github.com/TheStageAI/AppleSDK.git
cd AppleSDK/examples/tutor_small_llms
export TS_API_TOKEN=th_…

PACK=qwen06 GUIDE=1,2,3,4,5 swift run -c release
# PACK: lfm230 | lfm350 | qwen06 | gemma1b   GUIDE: any subset of 1..5

Guide 1 is the receipt, 2 the calendar, 3 the pickup code, 4 the router, 5 the tools. First run downloads the pack; later runs start from cache.

Results

  • Copy, don’t compute. Ask for the printed date, the day as said, the code as written. Normalise in Swift. This took the receipt date from 0/4 packs to 4/4.

  • Greedy for extraction, vendor card for chat. Extraction tables above are greedy and therefore repeatable; the tool answers use each family’s sampling card.

  • Few-shots are chat turns, never text in the system prompt.

  • Guard every copy: input.contains(value) before you trust a code or a date; strict JSONDecoder; allow-list labels.

  • Qwen3-0.6B is the safe default across all five jobs; LFM2.5-350M is close and faster; LFM2.5-230M is for the simplest shapes only; Gemma 3 1B cannot call tools.

Further reading