Skip to content

Add apple_fm integration for Apple's on-device model - #100

Open
piotrekno1 wants to merge 11 commits into
develfrom
feat/apple-fm-integration
Open

piotrekno1 wants to merge 11 commits into
develfrom
feat/apple-fm-integration

Conversation

@piotrekno1

@piotrekno1 piotrekno1 commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds an apple_fm integration for apple-fm-sdk, Apple's Python SDK for the on-device model behind Apple Intelligence.

With wildedge.init(integrations=["apple_fm"]):

  • LanguageModelSession.respond() and stream_response() each record one inference event with duration, prompt character/word counts and GenerationOptions (temperature, max tokens, sampling top-k/threshold/seed). Streams also record time to first token. Stream snapshots are cumulative and are passed through unchanged.

  • Token counts are opt-in. With WILDEDGE_APPLE_FM_COUNT_TOKENS=1, SystemLanguageModel.token_count() counts the prompt and the reply in a background task, and the event carries tokens in, tokens out and tokens per second. Each count takes about 40 ms on an M2 Mac. The call isn't slowed, but macOS queues counts behind generation, so a request sent right after waits about 80 ms. That's why counting is off by default.

  • SystemLanguageModel.is_available() records a model load. When the model can't be used, the load fails with the SDK's reason as its error code (APPLE_INTELLIGENCE_NOT_ENABLED, DEVICE_NOT_ELIGIBLE, MODEL_NOT_READY). An event is sent only when the answer changes, since apps poll while the model downloads.

  • Failures record an error event with a specific error code, plus the SDK's error name in provider_error_code:

    SDK error Error code
    assets_unavailable MODEL_UNAVAILABLE
    exceeded_context_window_size CONTEXT_LENGTH_EXCEEDED
    guardrail_violation, refusal CONTENT_FILTERED
    rate_limited, concurrent_requests RATE_LIMITED
    unsupported_guide, unsupported_language_or_locale, invalid_schema BAD_REQUEST
    decoding_failure INVALID_OUTPUT
    tool_call TOOL_CALL_FAILED

    RATE_LIMITED and BAD_REQUEST match the codes the server already derives for remote APIs (analytics/error_codes.py). The server keeps a specific code as sent, so no server change is needed. The other five are new ErrorCode values, listed in docs/manual-tracking.md.

  • Sessions share one handle per use case: apple-foundation-model or apple-foundation-model-content-tagging. The model version is the macOS version. from_transcript() is patched too, because the SDK creates that session as cls(_ptr=...) and __init__ never sees the model.

  • The accelerator is npu, a new name in wildedge.accelerators that the Swift and Android SDKs already use. Apple doesn't document where the model runs, so I measured it with powermetrics on an M2: while the model generates, Neural Engine power goes from 0 to about 4.9 W, GPU from 38 to 67 mW, and CPU from 51 mW to 1.5 W (the app and the model service).

Docs: docs/apple_fm.md, an error code table in docs/manual-tracking.md, an integration settings section in docs/configuration.md, a README row, an example, a note in docs/providers.md on fm serve (macOS 27) via the openai integration, and changelog entries.

Known gaps

  • The last call before the event loop closes loses its token counts. asyncio.run() cancels the pending count. The event is still sent, without tokens.
  • Tokens in counts the prompt only, not the instructions or earlier turns.

Testing

  • Unit tests (tests/test_integrations_apple_fm.py) run against a fake apple_fm_sdk that copies 0.2.1's signatures. Full suite: 949 passed.
  • Run against the real model on macOS 27.0.1 (M2, Mac14,9), with events sent to a dev project:
    • Before the model finished downloading: a failed load with MODEL_NOT_READY, and AssetsUnavailableError from a call made anyway.
    • Once ready: a successful load, then respond() and stream_response() inference events with duration and time to first token. With counting on, respond() recorded 5 tokens in and 37 out at 23.9 tokens/s.
  • The compat tests pass locally against the real SDK, with counting on and off. A new one resumes a content-tagging session with from_transcript(), and it fails without the fix. Running it with the model available showed that the content tagging model can't finish a reply in 8 tokens, so the test now allows 64.
  • Compat row apple_fm (apple-fm-sdk==0.2.1, Python 3.12/3.13) on a macos-26 runner. Unverified: I haven't confirmed that the runner label exists or that the SDK builds there. CI can't enable Apple Intelligence, so there the compat test covers the signatures the patches rely on, the failed load, and the failed call.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Coverage report

  • Total: 90% (12789/14214 lines)

Top uncovered files

  • wildedge/integrations/transformers.py: 63.2% (111 lines missed)
  • wildedge/integrations/mlx.py: 43.0% (98 lines missed)
  • wildedge/integrations/ultralytics.py: 67.8% (84 lines missed)
  • wildedge/integrations/pytorch.py: 64.5% (70 lines missed)
  • wildedge/platforms/macos.py: 57.1% (67 lines missed)

piotrekno1 and others added 5 commits October 25, 2026 00:46
Track LanguageModelSession.respond() and stream_response() from
apple-fm-sdk: duration, time to first token for streams, prompt size,
generation options, and the SDK's error names on failure. Sessions
share one model per use case, versioned by the macOS release.

Token counts stay empty: the SDK reports no usage, and counting would
cost an extra model call per request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apple's on-device model never returns an HTTP status, so the server had
nothing to derive a code from and stored every failure as UNKNOWN. The
integration now sends a specific code for each SDK error and keeps the
SDK's name in provider_error_code.

New codes MODEL_UNAVAILABLE, CONTEXT_LENGTH_EXCEEDED and CONTENT_FILTERED
cover failures the existing codes don't. RATE_LIMITED and BAD_REQUEST match
the names the server already derives for remote APIs. The server keeps a
specific code as sent. docs/manual-tracking.md now lists every code.
An app that checks SystemLanguageModel.is_available() and falls back when
the model can't be used left no trace. is_available() now records a model
load. When the model is unavailable the load fails with the SDK's reason
as its error code: APPLE_INTELLIGENCE_NOT_ENABLED, DEVICE_NOT_ELIGIBLE or
MODEL_NOT_READY.

Apps poll is_available() while the model downloads, so an event is sent
only when the answer changes, per process and model.
The example printed the bare enum value, such as "unavailable: 2".
Both were recorded as UNKNOWN. DecodingFailureError means the model's
output couldn't be parsed into the requested structure, so it is now
INVALID_OUTPUT. ToolCallError wraps an exception from the app's own tool,
so it is now TOOL_CALL_FAILED and tells app failures apart from model
failures. Both codes fit other integrations too.
@piotrekno1
piotrekno1 force-pushed the feat/apple-fm-integration branch from 2795bc3 to d478a1d Compare October 24, 2026 22:46
The test had only run where the model was unavailable. With the model
ready, the content tagging model can't finish a reply in 8 tokens, and
the SDK raises DecodingFailureError. 64 tokens is enough.
apple-fm-sdk reports no token usage. SystemLanguageModel.token_count()
counts tokens without generating, at about 40 ms per call on an M2 Mac.
With the variable set, respond() and stream_response() count the prompt
and the reply in a background task and send the inference event with
tokens in, tokens out and tokens per second.

The call returns as soon as the model has answered. macOS queues counts
behind generation, so a request that follows right after waits about
80 ms, which is why counting is opt-in. A done callback sends the event
whether counting finished, failed or was cancelled before it ran, as when
asyncio.run() closes the loop.

The setting is an env var, not an init() parameter, so wildedge run can
use it too. docs/configuration.md lists it under integration settings.
The docs, changelog and module docstring opened with "the SDK doesn't report token usage". SystemLanguageModel.token_count() counts them, and the integration uses it when WILDEDGE_APPLE_FM_COUNT_TOKENS is set.
The integration reported coreml because Apple doesn't say where the system
model runs. powermetrics on an M2 settles it: while the model generates,
Neural Engine power rises from 0 to about 5 W, GPU power stays near idle
(38 to 67 mW) and CPU power rises to 1.5 W for the app and the model
service.

npu is a new accelerator name. The Swift and Android SDKs already use it.
LanguageModelSession.from_transcript(transcript, model=...) creates the
session as cls(_ptr=...), so the patched __init__ never saw the model.
A resumed content tagging session was recorded as the general model, and
token counts used the default model.

from_transcript() is now patched too, and registers the session for the
model passed to it. A compat test resumes a content tagging session on
the real SDK, and fails without the fix.
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