All notable changes to PrivateFoundationModels will be documented here. The format follows Keep a Changelog and this project adheres to Semantic Versioning.
- LoRA / DoRA adapter loading on the MLX backend.
MLXLanguageModel.load(_:adapter:)loads a base model and applies a fine-tuned adapter on top, in memory — the sameLanguageModelSession.respond(...)call site then runs the adapted model with no other changes.MLXLanguageModel.Adapter—.directory(URL)for a local adapter dir,.huggingFace(repo, revision:)to download one. Either must hold themlx_lm.loralayout:adapter_config.json(fine_tune_type="lora"/"dora") plus*.safetensors.- Backed by mlx-swift-lm's
ModelAdapterFactory+LanguageModel.load(adapter:). The adapter is applied into the container's model layers, so every later generation uses it. modelIdentifierbecomesmlx://<base>+adapter:<id>so logs and multi-model routing can tell an adapted backend apart.
- CoreML adapters work differently and need no new API: CoreML-LLM
has no runtime adapter-apply step, so a fine-tuned CoreML model
is merged into the weights at conversion time and shipped as
a standalone bundle. Load it with the existing
CoreMLLanguageModel.load(localBundle:)— see the doc comment there. Apple FM adapters were already supported viaAppleFoundationModel.load(adapter:).
LanguageModelBackend.tokenCount(_ text: String) async -> Int?— backends can now expose their own tokenizer for honest tokens-per-second measurement. Default implementation returnsnil(used for Apple FM where the tokenizer is hidden). Concrete implementations:CoreMLBackendImpl→underlying.tokenizerRef.encode(...).countQwen3Backend→tokenizer.encode(...).countMLXBackend→await underlying.perform { _, tok in tok.encode(...).count }
PFMiPhoneBenchCSV gainsoutput_tokens,tok_per_sec_e2e,tok_per_sec_decodecolumns. Persists a fresh header before the first backend runs so external monitors can distinguish "this run hasn't started" from "leftover file from last run".
The Gemma 4 E2B numbers quoted in v0.10.7 were derived as
chars_per_sec ÷ 4 chars/token, which is wrong for the Gemma 4
SentencePiece tokenizer on technical English — the real ratio
is ~5.8 chars/token (Swift identifiers and short keywords pack
tighter). With actual tokenizer-driven counts:
- CoreML / ANE Gemma-4-E2B decode: 34.6 tok/sec (was claimed 50)
- MLX / GPU Gemma-4-E2B 4-bit decode: 45.2 tok/sec (was claimed 65)
- MLX vs CoreML decode gap: 1.31× (unchanged — the ratio was right even though the absolute numbers were 45% too high).
docs/RUNTIME_COMPARISON.md now has an explicit "methodology
note (and a correction)" paragraph. The hero chart
docs/media/gemma4-runtime-iphone.png is regenerated with real
tok/sec on the y-axis.
The older Qwen / LFM / Apple FM rows in docs/BENCHMARKS.csv
are still chars-per-sec only — they predate the tokenCount API
and will get backfilled the next time their respective benches
run on a v0.10.8+ build.
CoreMLLanguageModel.load(localBundle: URL, identifier: String?, ...)— sideload a CoreML-LLM bundle from a local directory, skipping the HuggingFace fetch path. Powers the iPhone bench'sDocuments/Models/auto-discovery.- PFMiPhoneBench: new Gemma 4 E2B head-to-head plan pair
— CoreML/ANE FP16 (sideload) vs MLX/GPU 4-bit (download).
Flag
runFullMatrixinBenchView.swiftflips between the Gemma-only comparison and the full 5-backend sweep. docs/media/gemma4-runtime-iphone.png+ new "runtime gap is architecture-dependent" section indocs/RUNTIME_COMPARISON.md.
First Gemma 4 E2B numbers on iPhone18,1 / iOS 26.4.2:
- CoreML / ANE FP16 (sideloaded): TTFT 673 ms, decode 199 chars/sec ≈ 50 tok/sec
- MLX / GPU 4-bit (downloaded): TTFT 85 ms, decode 261 chars/sec ≈ 65 tok/sec
The MLX-vs-CoreML decode gap collapses from 2.9× on Qwen3.5-0.8B to 1.3× on Gemma 4 E2B. Same iPhone, same prompt; the only difference is the model architecture. Gemma 4's matformer per-layer-embedding layout appears to be ANE-friendly enough that it hides the GPU quant advantage. Takeaway: "CoreML is slow" is wrong as a blanket — pick the runtime to match the architecture.
- New
decode_chars_per_seccolumn on every bench row (PFMBenchKit- PFMiPhoneBench CSV) plus
BenchRow.medianDecodeCharsPerSec/medianCharsPerSecaccessors. Defined asoutput_chars / (total_ms − ttft_ms)— pure decode rate with prefill stripped out, the apples-to-apples runtime number.
- PFMiPhoneBench CSV) plus
- Backfilled the new column into
docs/BENCHMARKS.csv(8 rows) anddocs/BENCHMARKS_MULTILANG.csv(15 rows).
- README hero chart switched to decode-only throughput on the right panel — the previous E2E chart was double-counting TTFT (both the left-panel ms and the right-panel cps penalized prefill), making CoreML look slower than its decode loop actually is. The numbers update accordingly: M4 Max MLX vs CoreML widens from 5.0× → 5.8× on decode; iPhone widens from 2.6× → 2.9×.
docs/RUNTIME_COMPARISON.mdanddocs/BENCHMARKS.mdrewritten to show both throughput columns side-by-side with an explicit tok/sec sanity check (CoreML decode-only ≈ 50 tok/sec on iPhone, matching the widely-reported 49 tok/sec for the same Qwen3.5 CoreML build).pfm-bench-*summary output andmarkdownRow()now print both E2E and decode-only chars/sec.
Earlier chars_per_sec numbers (still preserved in the CSV) were
honest E2E latencies — useful for "how does this feel in a user
flow" — but they fold TTFT into the throughput denominator, so a
slow-prefill backend looks worse than its steady-state decode
deserves. Use decode_chars_per_sec when comparing runtimes.
- First iPhone bench rows in
docs/BENCHMARKS.csv— captured end-to-end oniPhone18,1(iPhone Air) / iOS 26.4.2 via thePFMiPhoneBenchapp. Apple FM + CoreML/ANE Qwen3.5-0.8B + MLX/GPU Qwen3.5-0.8B-4bit. The runtime gap holds on iPhone too: MLX 7× faster TTFT (80 ms vs 560 ms), 2.6× higher throughput (385 vs 147 chars/sec) for the same weights. docs/RUNTIME_COMPARISON.mdrewritten with a second-tier iPhone Air table, an explicit "the gap holds on iPhone too" callout, and an updated chart that puts M4 Max and iPhone Air side-by-side.
- README hero alt-text + caption updated — chart now shows both M4 Max and iPhone Air data, so it's no longer "M4 Max numbers" but "Mac and iPhone, verified end-to-end".
Examples/PFMiPhoneBench/PFMiPhoneBench/BenchView.swiftnow writespfm-bench-latest.csvafter every completed backend (not just at the very end of all 4). One backend hanging / crashing no longer loses the rows that already finished.
mlboydaisuke/lfm2.5-350m-coremlmlpackagefailed to build on iOS 26.4.2 withCoreML failed to build model. Same model loads fine on macOS 26.0. Likely an iOS-side opset / SSM op gap in the CoreML compiler — being tracked.
Examples/PFMiPhoneBench/— one-tap iOS bench app. Open it, tap Run all, walk away ~5 minutes. Benches Apple FM (iOS 26+), CoreML LFM2.5-350M, CoreML Qwen3.5-0.8B, MLX Qwen3.5-0.8B-4bit sequentially with explicit release between backends. CSV written to Documents + clipboard + offered via Share Sheet. Device label usessysctl hw.machine+UIDevice.systemVersionso output rows tag withiPhone17,1 / iOS 26.1style identity.- Generates the project via
xcodegenfromproject.yml.
BenchLanguageenum +Bench.runAllLanguageshelper in PFMBenchKit. Eachpfm-bench-*exec accepts--multilangto run the harness once per curated language (English / Spanish / Korean / Japanese / Chinese).docs/BENCHMARKS_MULTILANG.csv— M4 Max baseline across all 5 languages × all 3 runtimes (15 rows).docs/MULTILANG_BENCH.md+docs/media/multilang-comparison-m4max.png— observations: Spanish is the throughput champion across all backends; MLX/GPU wins everywhere on M4 Max; CJK rows produce shorter outputs (fewer chars but more bits per char).
docs/RUNTIME_COMPARISON.md— sameQwen3.5-0.8B, same prompt, same M4 Max. CoreML/ANE FP16 vs MLX/GPU 4-bit: 12.2× TTFT, 5.0× throughput. Plus Apple FM 3 B and CoreML LFM2.5-350M as reference points. Explicit precision disclaimer.docs/media/runtime-comparison-m4max.pngmatplotlib chart generated fromdocs/BENCHMARKS.csv. Embedded as the README hero.- Updated
bin/post-x.pyDEFAULT_TWEET to the runtime-comparison hook (251 / 280 chars).
pfm-bench-{apple,coreml,mlx}accept:--csv— emit CSV row to stdout (header on first line).--csv-append <path>— append rows to PATH; creates with header if missing.--hardware <label>— override the auto-detected CPU brand string (defaults tosysctl machdep.cpu.brand_string).
docs/BENCHMARKS.csvseeded with the Apple M4 Max baseline so other contributors can append their rows.BenchRow.csvRow(...)+BenchRow.csvHeader+emitBenchOutput([...])helpers in PFMBenchKit.
ModelRegistryclass in PFMServeKit.pfm-serve-*execs now accept repeated--model(and--embedding-modelon MLX) flags; each becomes a registered backend. Request body'smodel:field routes per-call. First-registered fallback for unknown / missing values.PFMServer.init(options:registry:)is the new primary form.init(options:modelLabel:)kept as a convenience for source-compat./v1/modelslists every registered chat and embedding backend.- Per-request: a fresh
SystemLanguageModel(backend:)wraps the resolved backend and is passed toLanguageModelSession(model:). No global mutation; concurrent requests safe.
- Two MLX chat models in one process (
Qwen3.5-0.8B+FastVLM-0.5B) on Apple M4 Max,model:field routing between them, unknown-id fallback to first-registered — all correct.
- v0.8.1 vision — Downloaded
mlx-community/FastVLM-0.5B-bf16(1.2 GB). Sent a 256×256 test image (red top-left / green top-right / blue bottom-center squares) via OpenAI content array. Model correctly identified red top-left + green top-right; blue position called "bottom-left" instead of "bottom-center" — model-quality issue at 0.5B, not framework. Full HTTP → base64 → CGImage →respond(to:image:)chain verified. Captured indocs/pfm-vision-sample.txt. - v0.9.0 embeddings — Downloaded
sentence-transformers/all-MiniLM-L6-v2(87 MB). MLXEmbedder pipeline (tokenize → right-pad → attention mask → BERT forward → mean pool → L2 normalize) produced 384-dim consistent vectors; cosine matrix correct on the diagonal; Swift↔Swift (0.847) > Swift↔cake (0.77, 0.84) — semantic ranking PASS. Captured indocs/pfm-embeddings-sample.txt.
- Removed "experimental" marker on
MLXEmbeddersource + Examples/PythonClient README.
- Streaming tool calls.
stream: true+tools[]now emits OpenAI-shaped tool-call delta chunks (role→ tool-call metadata →function.arguments→finish_reason:"tool_calls"→[DONE]). Verified end-to-end via the officialopenaiPython SDK's chunk accumulation. bin/post-tabs.sh— opens all 4 pre-filled launcher URLs in the default browser at once.bin/post-x.py— Twitter v2 API poster (env-var-driven; dry-runs without creds).bin/post-reddit.py— Reddit script-app poster (env-var-driven; dry-runs without creds).Examples/PythonClient/openai_stream_tools_demo.pycaptures the SDK accumulation pattern for documentation.
EmbeddingBackendprotocol in PrivateFoundationModels.SystemLanguageModel.defaultEmbedderprocess-wide slot.- POST
/v1/embeddingson pfm-serve. OpenAI shape on both sides; returns 503 with a clear message when no embedder is installed. MLXEmbedderwrapping mlx-swift-lm'sEmbedderModelContainer. Standard BERT-style tokenize → pad → mask → forward → pool → L2-normalize pipeline. Probes output dim on load.pfm-serve-mlx --embedding-model <repo>flag wires the MLXEmbedder into the server.Examples/PythonClient/openai_embeddings_demo.py.
- The MLXEmbedder.embed() tensor pipeline was marked experimental in this release because it hadn't been run against a real embedding repo. v0.9.2 lands real-model verification.
- OpenAI vision content arrays.
messages.contentcan be a string or an array of{type: text|image_url}parts.image_url.urlacceptsdata:image/<mime>;base64,...URIs (decoded inline) andhttps://...URLs (fetched synchronously). First image flows tosession.respond(to:image:)/streamResponse(to:image:); text-only backends (Apple FM) silently drop the attachment. - Streaming + content arrays supported in the same path.
- OpenAI function calling over HTTP.
/v1/chat/completionsaccepts the standardtools: [{type, function: {name, description, parameters}}]shape, injects the catalog into the system prompt, parses the model's{"tool_call":{"name":..., "arguments":...}}reply into OpenAI'stool_callsresponse shape withfinish_reason: "tool_calls". - Round-trip support: client sends back the tool result as
{"role":"tool", "tool_call_id":..., "content":...}and the server feeds the prior turn into the prompt context. assistantmessages withtool_callsrendered into prompt context so the model has full history.Examples/PythonClient/openai_tools_demo.pydrives a two-turn function call against Apple FM via the officialopenaiSDK.
- Streaming + tools queued for v0.9.1.
- JSON mode honored in the streaming
/v1/chat/completionspath too. The same strict-JSON instruction the non-streaming path uses is injected into the system prompt; mid-stream fence stripping is intentionally not attempted (chunk boundaries would split the fence).
- CORS support throughout pfm-serve.
OPTIONS /v1/*preflights return 204 withAccess-Control-Allow-Origin: *,Access-Control-Allow-Methods: GET, POST, OPTIONS, andAccess-Control-Allow-Headers: content-type, authorization. Every other response carriesAccess-Control-Allow-Origin: *by default. Browserfetch()againsthttp://127.0.0.1:11434Just Works. - OpenAI JSON mode: when the request includes
response_format: { "type": "json_object" }or"json_schema", the server appends a strict JSON-only instruction to the system prompt and post-processes the reply throughJSONExtraction.extractObject(...)so the assistantcontentis a bare JSON object string — no```json ... ```fence wrapping. - HTTP/1.1
204 No Contentstatus text wired into the response serializer (was previously emitted as204 Unknown).
curl -X OPTIONS … /v1/chat/completions→ 204 + CORS headers.curl … -d '{"response_format":{"type":"json_object"}, …}'→ bare{"city":"Paris","country":"France"}content.
/v1/chat/completionsnow honors"stream": trueand replies with Server-Sent Events shaped exactly like OpenAI'schat.completion.chunk:- Initial
delta.role = "assistant"chunk. - One chunk per incremental
delta.contentslice as PFM's cumulative streamResponse advances. - Final chunk with
finish_reason: "stop", followed bydata: [DONE]\n\n. Backend errors mid-stream are emitted as anerrorSSE event before[DONE].
- Initial
docs/pfm-serve-stream-sample.txt: real captured streaming exchange from Apple FM (11 chunks).
- Framing uses
Connection: close(no chunked encoding), which is the simplest pattern that works with curl, the OpenAI SDKs, EventSource browsers,requests+sseclient, etc.
pfm-serve-apple/pfm-serve-coreml/pfm-serve-mlxOpenAI-compatible HTTP servers. Each exposes:POST /v1/chat/completionsPOST /v1/completions(delegates to chat-completions)GET /v1/models(returns the installed backend's identifier)GET /healthz
- New shared library
PFMServeKitimplementing minimal HTTP/1.1 on top ofNetwork.framework'sNWListener. Zero new package dependencies — chunked-encoding bodies not yet supported; standardContent-Lengthbodies from curl / the OpenAI SDKs / requests / axios work out of the box. docs/pfm-serve-sample.json: real captured response from Apple's native model through the HTTP endpoint.
- Streaming (
"stream": true→ Server-Sent Events) is not implemented in this release; requests are answered synchronously. SSE lands in v0.7.1. - The server runs on
127.0.0.1by default. Pass--host 0.0.0.0to expose it on the LAN at your own risk.
AppleFoundationModel.UseCaseenum (.general/.contentTagging) andAppleFoundationModel.load(useCase:)factory. Mirrors Apple'sFoundationModels.SystemLanguageModel.UseCase.AppleFoundationModel.Adapterenum (.name(String)/.fileURL(URL)) andAppleFoundationModel.load(adapter:) throwsfactory. Mirrors Apple'sSystemLanguageModel.Adapter(name:)/Adapter(fileURL:)initializers so apps can load fine-tuned Apple FM adapters without importing FoundationModels directly.
- The original
AppleFoundationModel.load()(no-arg) is unchanged and still wires toSystemLanguageModel.default. The two new overloads are additive.
respond(to:generating:T.self)auto-retries onGenerationError.decodingFailure. DefaultmaximumRetries: 2(so a max of 3 backend calls), configurable per call. Retry prompts append a JSON-encoded schema reminder so the model sees exactly what shape it failed to produce. Apple's native backend rarely trips this because its grammar-constrained sampler enforces schema directly; CoreML and MLX benefit when small models occasionally emit invalid JSON.maximumRetries: 0restores single-shot Apple-FM-strict behavior for callers that want it.- 3 new tests
(
generableAutoRetriesOnDecodingFailure,generableThrowsAfterRetriesExhausted,generableMaximumRetriesZeroDisablesRetry) cover the contract.
- Two existing tests
(
respondGenerableFailsOnGarbledJSON/decodingFailureReturnsRawText) passmaximumRetries: 0explicitly so they keep exercising the single-shot decode-failure path now that the default value is 2.
pfm-bench-apple/pfm-bench-coreml/pfm-bench-mlxstandardized benchmark executables. Same prompt × sameGenerationOptions× 3 timed iterations + 1 warmup per backend. Each emits load_ms, time-to-first-token, total_ms, output_chars, chars/sec, and a drop-in markdown row.- New
PFMBenchKitshared library backing the three executables. docs/BENCHMARKS.mdupdated with M4 Max / macOS 26.0 baseline: Apple FM (3 B native) — TTFT 297 ms / 252.7 chars/sec, CoreML LFM2.5-350M — TTFT 533 ms / 38.6 chars/sec, CoreML Qwen3.5-0.8B — TTFT 530 ms / 155.1 chars/sec, MLX Qwen3.5-0.8B 4-bit — TTFT 42 ms / 821.2 chars/sec.
BackendGeneration.transcriptDelta: backends can report transcript entries they produced internally so the session appends them to its own audit trail.- Apple FM backend snapshots
session.transcriptbefore/after the call and translates Apple-side.toolCalls/.toolOutputentries back to PFMTranscript.Entryvalues via the new field. - Test
transcriptDeltaIsAppendedBeforeResponselocks the contract.
pfm-apple-deepmatrix: jumped from PASS 10 / MODEL 4 / FAIL 0 to PASS 14 / MODEL 0 / FAIL 0 — tool turns are now visible insession.transcripton Apple too.
PFMToolAdapter(runtime bridge): conforms toFoundationModels.ToolwithArguments = GeneratedContent, routes eachcall(arguments:)back through PFM'sAnyTool.invokeso the user'sfunc call(arguments:)runs against PFM-decoded Generable arguments exactly as on the CoreML / MLX backends.- Apple FM backend now constructs
[PFMToolAdapter]from incoming PFM tools and passes them toLanguageModelSession(model:tools:transcript:). - Apple's
LanguageModelSession.ToolCallErroris unwrapped to the underlying error so PFM callers see exactly what their tool threw.
- Removed the
guardUnsupported(tools:)guard. Tools work now. pfm-apple-deepre-enabled the Tool phase viarunner.runAll().
@Generablecross-translation for the Apple FM backend.pfmSchemaToDynamic(_:name:)walks PFM's JSON-Schema-shapedGenerationSchemaand produces an AppleDynamicGenerationSchema, which is fed toFoundationModels.GenerationSchema(root:dependencies:)andrespond(to:schema:). Apple's returnedGeneratedContentis re-serialized as JSON viageneratedContentToJSON(_:)so PFM's existing Generable decoder takes over.- New
pfm-apple-deepexecutable mirrorspfm-deep/pfm-mlx-deepbut routes through Apple FM. PASS 9 / MODEL 0 / FAIL 0 on all 6 Generable shapes + Multimodal + PromptBuilder phases. PFMDeepKitscenario phase methods promoted topublicso downstream executables can pick which phases to run.
PrivateFoundationModelsAppleproduct — passthrough to Apple's native FoundationModels framework on iOS 26+ / macOS 26+ / visionOS 26+. The sameLanguageModelSession.respond(...)call site that runs on a CoreML/MLX model on iOS 18 routes directly to Apple's actual on-device LLM here.AppleFoundationModel.load(),.availability,.isAvailablehelpers +AppleFoundationModelBackend.- Transcript / GenerationOptions / SamplingMode translation between PFM and Apple types.
- New
pfm-apple-smokeexecutable. - Verified on macOS 26.0: load 0 ms, respond 1.2 s, stream OK.
- MLXVLM dependency. Linking it registers the VLM model factory with
ModelFactoryRegistry.sharedvia NSClassFromString trampoline, soloadModelContainer(id:)auto-routesmlx-community/*-VL-*repos to the VLM factory and falls back to the LLM factory for text models. MLXLanguageModel.Catalog.qwen25_VL_7B_4bit/.qwen2_VL_7B_4bit.- Try-image-then-fallback in
MLXBackend: VLM models accept the attachment, text-only LLMs silently drop it instead of crashing.
- Backend-agnostic scenario library extracted into
PFMDeepKit. pfm-deepbecomes a CoreML wrapper,pfm-mlx-deep(new) is the MLX equivalent. Both call the sameDeepRunner.
- Streaming
Generablepartial-snapshot decode (Apple FM cadence parity).streamResponse(to:generating:)now emits partial decodes of the target type as soon as enough JSON is on the wire to parse a prefix. Implemented viaJSONExtraction.extractPartialObject/PartialJSONParserstate machine. 19 new unit tests +streamingGenerableEmitsIncrementalSnapshotsintegration test. PrivateFoundationModelsMLXproduct. Wrapsml-explore/mlx-swift-lm, routes generation to anymlx-community/*model under PFM's call site.MLXLanguageModel.Catalogships Qwen3-4B, Llama-3.2-3B, Gemma-2-2B, Mistral-7B, Phi-3.5-mini.- New
pfm-mlx-smokeexecutable proves the path againstmlx-community/Qwen3.5-0.8B-MLX-4bit(load 2.0 s, respond 1.4 s).
- README: lead rewritten ("One call site. Three backends." — the iOS 18 polyfill that becomes a runtime passthrough on iOS 26).
- Roadmap bumped.
Rolls up beta.1 and beta.2 into the first stable v0.2 release.
Promptvalue type withExpressibleByStringLiteral+Codable.@PromptBuilderresult builder sosession.respond { "..." }trailing closures compile against either Apple's framework or this one. Builder joins segments with double newlines, matching Apple's expected output.LanguageModelSession.respond(options:prompt:)/streamResponse(options:prompt:)overloads (string andGenerableoutputs) for the trailing-closure call style.Guardrailsvalue type with.default. Apple-shapedLanguageModelSession(model:guardrails:tools:instructions:)init now compiles. v0.2 ships an accept-all no-op; v0.3+ will support real policy configuration.- Vision input:
respond(to:image:options:)andstreamResponse(to:image:options:)take an optionalCGImage. Plumbed into a newBackendAttachmentvalue type passed to backends via overloadedLanguageModelBackend.generate(transcript:attachments:...)/streamGenerate.
Qwen3Backend: drives Qwen3.5 0.8B / 2B via CoreML-LLM'sQwen35MLKVGenerator. The Qwen catalog entries that returnedconfigNotFoundin v0.1 now load and generate end-to-end. Tokenizer is pulled fromQwen/Qwen3.5-{0.8B,2B}automatically via the newCatalog.tokenizerSourceRepomapping +HFFetcher.ensureFiles(...).CoreMLBackendImploverrides the multimodal entry points and forwards the first.image(CGImage)attachment toCoreMLLLM.generate(messages:image:)/.stream(messages:image:). Vision-capable bundles (Gemma 4 E2B multimodal) light up; text-only bundles transparently fall back.
HFFetcherretries transientURLSessionerrors (NSURLErrorNetworkConnectionLost,NSURLErrorTimedOut, etc.) up to 4 times with exponential backoff. Necessary because HF's Xet LFS backend drops multi-GB downloads more often than fresh S3.JSONExtraction.stripThinkBlocksremoves<think>...</think>reasoning preambles before downstream JSON / tool extraction. Required for Qwen3 family; future DeepSeek-R1 etc. land for free.- Tool-call parser is layout-tolerant: accepts
TOOL_CALL: name\n{json},TOOL_CALL: name {json},TOOL_CALL: {json}(single-tool inference). - 16 new tests (Prompt + Guardrails + builder overloads + vision plumbing).
Examples/PFMSwitcher/PFMSwitcher/ChatView.swiftintegratesPhotosPicker; the picked image flows throughsession.streamResponse(to:image:)on send and is dropped after send.Examples/PFMPortability/AppleFMCode.swiftadds two more Apple-FM-shaped scenarios:describeImage(vision) andtranslateUsingPromptBuilder(PromptBuilder + Guardrails). 10 / 10 scenarios green on real model.Sources/PFMDeep/DeepMain.swiftexercises three new scenarios:respond(to:image:),streamResponse(to:image:),respond { @PromptBuilder }. All three PASS on LFM2.5-350M ANE.
CoreMLLanguageModel.load(...)return type widened fromCoreMLBackendImpltoany LanguageModelBackend(necessary to also returnQwen3Backend). All documented call sites pipe intoSystemLanguageModel(backend:)which takes the protocol, so the change is source-compatible at usage.
.qwen3VL2BStatefulstill requires the upstreamQwen3VL2BStatefulGeneratorand lands in v0.3.- Reasoning models (Qwen3 family) need a generous
maximumResponseTokensbudget forGenerablebecause the<think>preamble consumes the budget; no toggle to disable yet.
- Vision input on
LanguageModelSession:respond(to:image:options:)andstreamResponse(to:image:options:)both accept aCGImage?alongside the prompt. The image is forwarded to the backend via a newBackendAttachmentvalue type. Available on iOS 18+ / macOS 15+ / visionOS 2+. LanguageModelBackendgained a multimodal overload:generate(transcript:attachments:options:schema:tools:)and the streaming variant. Default extension implementations silently drop attachments before delegating to the text-only methods, so existing backends (AppleFMBridgeBackendin the PFMSwitcher sample, third-party custom backends) keep compiling and working without changes.BackendAttachment(Sources/PrivateFoundationModels/) — value type wrapping aCGImagefor now; the discriminator isenum Kindso audio (v0.8 roadmap) can join without a breaking change.- 5 new tests (
AttachmentTests) covering: image-with-respond plumbing, image-with-streamResponse plumbing, no-image-zero-attachments, nil-image-zero-attachments, text-only-backend-drops-image fallback.
CoreMLBackendImpl(default CoreML backend, the oneCoreMLLanguageModel.load(.gemma4E2B)returns) now overrides the multimodal entry points and forwards the first.image(CGImage)attachment toCoreMLLLM.generate(messages:image:maxTokens:)/.stream(messages:image:maxTokens:). Vision-capable models (Gemma 4 E2B multimodal build) light up; text-only models in the same family transparently fall back to text-only generation.
Qwen3Backend(the Qwen3.5 path) does not yet override the multimodal entry points — Qwen3-VL needs a different upstream Swift class (Qwen3VL2BStatefulGenerator) than the text-onlyQwen35MLKVGenerator. v0.3 will route.qwen3VL2BStatefulthrough the dedicated generator.
Qwen3Backend: aLanguageModelBackendthat drives Qwen3.5 0.8B / 2B through CoreML-LLM'sQwen35MLKVGeneratorANE path. The Qwen catalog entries that returnedconfigNotFound/ "model does not exist" in v0.1 now load and generate end-to-end (pfm-verify --model qwen3.5-0.8B→ 9/10 PASS; the one remaining miss is model-quality, not framework — Qwen3.5 0.8B occasionally emits a JSON array where a string-typed Generable field is required, and the framework correctly raisesdecodingFailure).CoreMLLanguageModel.Catalog.tokenizerSourceRepo: maps a CoreML repo to the upstream HuggingFace repo it borrows its tokenizer from. Used internally so the foreground fetcher pullstokenizer.json+tokenizer_config.jsonfromQwen/Qwen3.5-0.8B(etc.) when the mlboydaisuke CoreML repo doesn't include them.HFFetcher.ensureFiles(_:repo:in:token:onProgress:): download a hand-picked list of files from any HF repo. Backs the tokenizer pull-down above.- Retry loop on transient
URLSessionerrors inHFFetcher(NSURLErrorNetworkConnectionLost,NSURLErrorTimedOut, etc.) with exponential backoff. HF's Xet LFS backend drops multi-GB downloads often enough that this matters in practice. JSONExtraction.stripThinkBlocks: removes<think>...</think>and<thinking>...</thinking>reasoning preambles so the downstream JSON / tool-call extraction sees the model's final answer. Qwen3 family is the immediate motivation; future DeepSeek-R1 / o1-style models would use the same path.
CoreMLLanguageModel.load(...)return type went fromCoreMLBackendImpltoany LanguageModelBackendso the function can return either aCoreMLBackendImpl(LFM2.5 / Gemma 4) or aQwen3Backend(Qwen3.5). All real-world call sites pipe the return value intoSystemLanguageModel(backend:), which takesany LanguageModelBackend, so the change is source-compatible at every documented usage.pfm-verifyadjusted to cast when it wants CoreML-specific introspection (underlying.contextLength).- Tool-call parser is now layout-tolerant: accepts
TOOL_CALL: name\n{json},TOOL_CALL: name {json}, andTOOL_CALL: {json}(with single-tool name inference). The tool name is taken as the first whitespace-delimited token before the{— previous versions kept all interior whitespace and broke on small-model output likeTOOL_CALL: add\nSINGLE-LINE JSON arguments:.
.qwen3VL2BStatefulstill doesn't load — vision input on the session API ships in v0.3.- Reasoning models (Qwen3 family) need a generous
maximumResponseTokensbudget forGenerablebecause the<think>preamble eats into the budget. v0.2 doesn't expose a "thinking off" toggle;pfm-verifybumps the Generable budget to 768 tokens for the qwen3.5 case as a workaround.
@Generablemacro (member + extension attached) that walks a struct's stored properties and synthesizesstatic var generationSchema. Drop-in shape with Apple'sFoundationModels.Generablemacro: supports primitives, optional fields (which drop out ofrequired),[T]arrays, nested@Generabletypes, and macro-leveldescription:argument.@Guide(description:)peer attribute for per-field schema descriptions.HFFetcher: a foreground-URLSessionHuggingFace tree mirror used byCoreMLLanguageModel.load(...)so the first call downloads on its own from any plain-process context (CLI, Xcode Preview, unit tests). CoreML-LLM upstream's background-URLSessiondownloader is bypassed.CoreMLLanguageModel.load(_:cacheDirectory:hfToken:onProgress:)with new parameters for custom cache root and gated-repo authentication.defaultCacheDirectory(for:)static helper exposing the cache root.- New
PFMMacrosmacro target backed byswift-syntax 600.0. - 11 new tests (
GenerableMacroTests) covering primitive / optional / array / nested /@Guide/ macro-description / end-to-end paths.
PFMPortability/AppleFMCode.swiftnow uses@Generableand@Guide(description:)— the portability proof's structured-output fixture is now byte-identical to canonical Apple FM sample code.- README documents
@Generableas the recommended path; manualgenerationSchemais now an opt-out.
- Streaming
Generabledecode path now strips Markdown code fences through the sharedJSONExtractionhelper, matching the non-streaming path. (First surfaced inpfm-deep.) - Streaming
Generabledecode now wrapsSwift.DecodingErrorasGenerationError.decodingFailure— consistent error surface withrespond(to:generating:).
Initial release.
LanguageModelSessionwithrespond(to:),respond(to:generating:),streamResponse(to:),streamResponse(to:generating:),prewarm(),transcript,isResponding.Instructions,GenerationOptions,SamplingMode.Response<Content>andResponseStream<Content>(cumulative snapshots).TranscriptwithEntrykindsinstructions/prompt/response/toolCall/toolOutput, Codable round-trip viaserialized()/init(serialized:).Toolprotocol +AnyTooltype-erased wrapper, plusLanguageModelSession(tools: [any Tool], ...)convenience initializer.Generableprotocol withGenerationSchema(recursive JSON-Schema-shaped), primitives conforming out of the box.SystemLanguageModelwith.default(thread-safe, settable), pluggableLanguageModelBackendprotocol.GenerationErrormatching Apple's case names:concurrentRequests,refusal,decodingFailure,exceededContextWindowSize,unavailable,cancelled,backend.PrivateFoundationModelsCoreMLproduct:CoreMLLanguageModelfactory wrappingjohn-rocky/CoreML-LLMwith a 7-model catalog coveringmlboydaisuke/*HuggingFace repos.PFMChatexample iOS app (~200 lines, SwiftUI).- 30 unit tests covering API surface and session behavior.
- CoreML backend streaming: terminal snapshot was trimmed which broke Apple's documented cumulative-prefix invariant. Terminal snapshot now carries the same raw cumulative buffer as interior snapshots.
- CoreML backend tool-call JSON extraction: parser was too literal and
rejected output where the model prepended prose to the JSON object.
Replaced with a depth-counted, string-aware extractor that finds the
outermost balanced
{ ... }.
- No
@Generablemacro yet — conformers supplygenerationSchemaby hand. - Schema-constrained generation is enforced via system-prompt instructions and post-processing, not via a grammar-constrained sampler. Use a backend that supports a constrained sampler (Apple FM's grammar mode, Outlines, LM Format Enforcer) for deterministic schema enforcement.
- Tool calling uses a
TOOL_CALL: name\n{json}text protocol the model is asked to follow; robustness depends on the underlying model. - The Qwen3.5 / Qwen3-VL catalog entries do not load through this backend in
v0.1 — CoreML-LLM ships those families behind a separate Swift type
(
Qwen35MLKVGenerator). Verified safe catalog entries:.lfm2_5_350M,.gemma4E2B,.gemma4E4B. Seedocs/VERIFICATION.md. CoreMLLLM.ModelDownloaderusesURLSessionConfiguration.backgroundand does not run from a plain CLI process. From an iOS app context it works as documented in CoreML-LLM's README; for CLI / Mac verification pre-populate~/Documents/Models/<id>/withhuggingface-cli download.- No vision input on the
LanguageModelSessionAPI yet. The CoreML backend can run Qwen3-VL / Gemma 4 multimodal viaCoreMLLLMdirectly but the typed surface for image prompts is on the v0.6 roadmap. - No streaming partial-JSON decode for
Generabletypes other than fully parseable prefixes. Mid-stream snapshots are emitted only when the accumulated JSON parses as the target type.