The function-calling codelab: a tool, a loop, and a model you tuned yourself - #496
Open
DenisovAV wants to merge 6 commits into
Open
The function-calling codelab: a tool, a loop, and a model you tuned yourself#496DenisovAV wants to merge 6 commits into
DenisovAV wants to merge 6 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The fifth codelab. Four step apps under
codelabs/function-calling-flutter-gemma/, a fifth step that is deliberately not an app, and the text atwebsite/codelabs/function-calling-flutter-gemma/index.md. The "Function Calling" card on/codelabsstops being "Coming soon".The apps
step_01_starterstep_02_one_toolTool, and the call/response loop written out by handstep_03_the_loopgenerateChatResponseWithToolswithmaxToolTurns/onMaxToolTurnsstep_04_finetunetools.json, 72 training rows and the five litetune commandscompletetoolChoice, thinking mode, and a.litertlmopened from diskThe 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 theMIRRORStable 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_starteris Step 2 minus its tools:main.dart,model.dartanddownload_page.dartbyte-for-byte what Step 2 has, andchat_page.dartdiffering 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
FunctionCallResponseis 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
generateChatResponseWithToolsadds 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.multiplyalso 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 wholeparametersblock 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 settingprefer_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
litetunetakesgoogle/functiongemma-270m-itthrough 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, socomplete/opens it throughfromFileand not one line of Dart changes.fromFiledoes not copy — it registers the path — which is also why the delete button is safe on a tuned model:uninstallModelskips the file for aFileSourceinstall.step_04_finetune/has nopubspec.yaml, sotool/check_codelabs.sh(which discovers apps bypubspec.yaml) does not see it.codelabs/README.mdnow 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.shkeeps 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 mirroredlib/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: theflutter_gemma_litertlmweb arm does passtools, but the same runtime is described upstream as a text-only preview, so the codelab declines to promise either way.