Skip to content

The function-calling codelab: a tool, a loop, and a model you tuned yourself - #496

Open
DenisovAV wants to merge 6 commits into
mainfrom
feat/codelab-function-calling
Open

The function-calling codelab: a tool, a loop, and a model you tuned yourself#496
DenisovAV wants to merge 6 commits into
mainfrom
feat/codelab-function-calling

Conversation

@DenisovAV

@DenisovAV DenisovAV commented Sep 7, 2026

Copy link
Copy Markdown
Owner

The fifth codelab. Four step apps under codelabs/function-calling-flutter-gemma/, a fifth step that is deliberately not an app, and the text at website/codelabs/function-calling-flutter-gemma/index.md. The "Function Calling" card on /codelabs stops being "Coming soon".

The apps

dir what it is
step_01_starter a plain streaming chat on FunctionGemma 270M (284 MB, ungated) — Step 2 with the tools taken out
step_02_one_tool one Tool, and the call/response loop written out by hand
step_03_the_loop the same tool, driven by generateChatResponseWithTools with maxToolTurns / onMaxToolTurns
step_04_finetune not a Flutter app: tools.json, 72 training rows and the five litetune commands
complete Gemma 4 E2B, three tools, toolChoice, thinking mode, and a .litertlm opened from disk

The starter is this codelab's own app

It is not a copy of another codelab's complete/. The two codelabs that continue Getting Started still begin from its finished app and are still checked byte-for-byte against it; this one does not, and the MIRRORS table has no row for it.

The reason is the model. Getting Started ends on Gemma 3 1B — 0.5 GB, behind a Hugging Face licence gate, so its --dart-define=HF_TOKEN=... comes with it. This codelab never opens that model again: everything from Step 2 on is FunctionGemma 270M, which is 284 MB and ungated. Starting on a copy meant a learner downloaded half a gigabyte, accepted a licence, and then never used either.

So step_01_starter is Step 2 minus its tools: main.dart, model.dart and download_page.dart byte-for-byte what Step 2 has, and chat_page.dart differing by exactly the 287 lines of tool machinery Step 2 adds. Step 2's diff is now the lesson and nothing else — no model swap, no token, no gate.

Why the loop is written twice

Step 2 writes it out because the SDK's own contract is easy to miss: a FunctionCallResponse is committed to the chat's persistent history at the moment it is yielded, before your code has seen it. A call left without a reply poisons the next turn on that chat. So even the hand-written loop, which runs one round and stops, answers the calls it declines to run — and that is the beat Step 3 is sold on.

Step 3 then deletes all of it. What generateChatResponseWithTools adds is not the happy path, which Step 2 already had; it is the three exits that were not there at all — a cancelled turn, a tool that throws, a stream that errors mid-turn — each of which balances the history before it returns.

Why the tools are boring on purpose

multiply, get_current_time, get_device_info. A learner can check every one of them by eye with the network off, which is the whole argument for running a model on device. A weather API would demonstrate the same mechanism and prove nothing about the answer. multiply also has ground truth, which is what the integration test asserts against — "the reply is not empty" would not separate a model that used the tool from one that invented a plausible number.

The no-argument tools earned their place too: chat.dart's FunctionGemma renderer gates the whole parameters block on the map being non-empty, so a no-arg tool still has to declare {'type': 'object', 'properties': {}} or the model reads a declaration that was cut off.

The CPU line, and what it is not

Steps 1-3 pass preferredBackend: PreferredBackend.cpu, and the text spends a section on why. It is a workaround for this published file, not for the model and not for the platform: the artifact was converted before litetune 0.1.4 began setting prefer_activation_type=fp32, and without that key the GPU path answers every prompt with <pad> repeated to the token limit — no exception, no warning, a chat that looks alive and returns filler. Converted with the key, the same weights score the same on GPU as on CPU and run about 1.5× faster.

Saying "FunctionGemma needs a CPU" would have been the easy sentence and the wrong one. complete/ runs Gemma 4 on the GPU in the same app.

Step 4, and the point of it

litetune takes google/functiongemma-270m-it through LoRA, merge, export to .litertlm, and a measured comparison against the float twin. Its alpha is stated as measured end to end on that model and function calling only — which is exactly where this codelab already is, and the text says so rather than implying broader coverage.

The punchline is small and deliberate: the output is a .litertlm, so complete/ opens it through fromFile and not one line of Dart changes. fromFile does not copy — it registers the path — which is also why the delete button is safe on a tuned model: uninstallModel skips the file for a FileSource install.

step_04_finetune/ has no pubspec.yaml, so tool/check_codelabs.sh (which discovers apps by pubspec.yaml) does not see it. codelabs/README.md now says so, since it previously stated that every step directory is an app.

The step is optional and the text says the cost plainly — alpha software, ~2.2 GB of cached Python environments, CPU time, Linux or macOS only. Skip it and the app works on the stock models.

Gate

tool/check_codelabs.sh keeps two mirror pairs and gains an app-identity invariant: every app inside one codelab shares a bundle id, and no two codelabs share one. Both were proved to fail closed by perturbation — a comment line appended to a mirrored lib/model.dart, and a duplicated id — each producing ::error:: and exit 1 with the offender named, then reverted and re-run clean.

Caveats

No device ran integration_test/function_calling_test.dart, and litetune was never installed here — the commands are byte-identical to its README and the data is in the documented shape, but the pipeline was not exercised. Web function calling is described in the text as untested rather than as working: the flutter_gemma_litertlm web arm does pass tools, but the same runtime is described upstream as a text-only preview, so the codelab declines to promise either way.

Fifth codelab. Four step apps under codelabs/function-calling-flutter-gemma/
plus a fifth step that is not an app, and the text at
website/codelabs/function-calling-flutter-gemma/index.md.

step_01_starter is Getting Started's finished app, byte-identical in lib/ and
test/ — a third mirror pair, added to check_codelabs.sh's MIRRORS table and
proved to fail closed.

step_02_one_tool declares one Tool on FunctionGemma 270M and writes the
call/response loop out by hand, so the learner sees the three questions it
actually asks — and sees why a call the app declines to run still has to be
answered: the SDK commits a call to history the moment it yields it.

step_03_the_loop deletes that and calls generateChatResponseWithTools, with
maxToolTurns as the stop condition and onMaxToolTurns as the signal. What the
SDK adds over the hand-written version is the three exits it did not have —
cancellation, a throwing tool, a stream that errors mid-turn — each of which
balances the history before it returns.

step_04_finetune is data and commands, not an app: a tools.json and 72
prompt-to-tool-call rows for a litetune run over the same three tools, ending
in a .litertlm the app opens through fromFile. Not one line of Dart changes,
which is the point. It has no pubspec.yaml, so the gate does not discover it;
codelabs/README.md now says so, since it previously claimed every step
directory is an app.

complete pays 2.59 GB for Gemma 4 E2B and spends it on the two things a 270M
model cannot do: reason out loud, and be told it must call something. Three
tools dispatched by name, toolChoice and isThinking as session settings that
rebuild the chat, and a model list that finds a tuned model again on the next
launch through listInstalledModels + getModelPath.

Tools are the clock, a platform reading and multiplication — answers a learner
can check by eye with the network off, which is the whole argument for running
this on device.

Identity dev.fluttergemma.functioncalling, distinct from the other four.
All six platforms in every app.
Measured on macOS. The model this codelab opens with generates nothing but
`<pad>` repeated to the token limit when the engine is built for the GPU
backend — no exception, no warning, a chat that looks alive and returns
filler. Steps 2 and 3 shipped that way and would not have worked for anyone
who ran them.

On CPU the same weights ask for the tool correctly:

  [fg] calls: [multiply({a: 6, b: 7})]

So both steps now ask for `PreferredBackend.cpu`, and maxTokens drops 4096 →
1024, which is what this checkpoint is built for and the value the working run
used. A 270M model does not need a GPU; the codelab says so and says why.

Not a codelab bug: the example app configures the same model with
PreferredBackend.gpu, so it has the same problem off Android. Worth an issue
against the plugin — a silent `<pad>` is the failure mode nobody diagnoses.
…lly do

Review of #496 found the function-calling codelab asserting five things the
source contradicts.

- `ToolChoice.none` does not stop Gemma 4 seeing the declarations: the litertlm
  `createChat` forwards `tools:` regardless, and `none` only switches off the
  SDK's swallow — so a call under it streams raw tool_calls JSON into the
  bubble. Say that instead.
- `ToolChoice.required` is a no-op on Gemma 4 too: the "you must call" text
  lives on the Dart-injection path passthrough models skip, and `tools_json`
  carries no tool_choice. `supportsRequiredToolChoice` is now false for both
  models, so the app's own notice tells the truth at the point of use.
- Step 4's base checkpoint is HF-gated with manual approval. Scope the
  "no token needed" promise to the app, and put "request access first" where
  someone decides whether to start the step.
- Web function calling is known-broken from source, not untested: the browser
  runtime never overrides `createChat`, so the session gets no declarations.
- Drop the stale "so 4096" argument left under a `maxTokens: 1024` call.

Plus four minors: litetune's command numbering disambiguated from the codelab's
steps, step_02's stream-error hole disclosed where it happens, its one
unguarded setState guarded, and two claims softened to what was measured.
…acOS

I measured FunctionGemma answering `<pad>` on the GPU backend on macOS and
then forced PreferredBackend.cpu on every platform. The measurement was real;
the conclusion drawn from it was not. On Android the model runs on the GPU —
which is how the plugin's own example configures it — so a macOS-only result
was pessimising the platform most learners use.

The backend is now asked for only where the failure was seen, and the text and
the comment say so in as many words, including "do not read this as
FunctionGemma needs CPU".
I measured FunctionGemma answering <pad> on the GPU and blamed first the
backend, then macOS. Both were wrong. The cause is the published .litertlm:
it was converted before litetune set prefer_activation_type=fp32, and without
that key the GPU path floods <pad> on Android and on macOS Metal alike. A
re-conversion with a current litetune scores the same on GPU as on CPU and
runs about 1.5x faster.

So the CPU request goes back to every platform — it is a workaround for one
downloadable file — and the text says which file, why, and that Step 4's
re-conversion is what removes the need for it. That also gives the
fine-tuning step a payoff a learner can see before changing a single
training row.

Measured by the owner on Metal: without the key 40/40 rows returned pad and
nothing else; with it, 0.875 exact and 40/40 tool names, the same as CPU.
step_01_starter was a byte-identical mirror of the Getting Started codelab's
finished app, so it downloaded Gemma 3 1B — 0.5 GB behind a Hugging Face
licence gate — and Step 2 immediately replaced it with FunctionGemma 270M and
never opened it again. A learner paid half a gigabyte and a licence request for
a model this codelab does not use.

A starter does not have to be another codelab's finished app. This one is now
Step 2 minus the tool machinery: the same 284 MB ungated FunctionGemma, a plain
streaming chat, no token plumbing anywhere. main.dart, model.dart and
download_page.dart are byte for byte Step 2's, so the diff between the two
steps is exactly tools.dart plus the loop in chat_page.dart.

The mirror pair is dropped from MIRRORS in tool/check_codelabs.sh; the other
two rows and the fail-closed behaviour are untouched, and the check still
reports drift on a perturbed pair. The codelab text drops the Getting Started
prerequisite, says the app needs no Hugging Face token in any step, and states
the download as 284 MB for Steps 1-3 plus 2.59 GB for Gemma 4 in Step 5.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant