openclaw/extensions/apple-fm/setup.ts
Peter Steinberger 035227797a
feat(onboarding): add Apple on-device setup and utility model (#150376)
* feat(apple-fm): offer on-device inference during Mac setup

Add a macOS-only setup option backed by the native Foundation Models API.
Measure availability and context size, compile the helper during explicit
setup, and keep tool execution and validation with OpenClaw.

Cover native tool handoff, transcript continuation, constrained generation,
platform selection, setup isolation, cancellation, and packaged asset roots.

Refs #109228

* fix(apple-fm): gate setup on background context detection

Offer Apple Foundation Models only after a cancellable background probe confirms an available model with at least 8192 context tokens. Cold discovery compiles a disposable helper with installed Apple tools without installing inference or changing configuration; selection prepares the persistent helper and rechecks eligibility.

Add detected-only auth-choice visibility for classic and app-guided setup. Keep unknown, unavailable, undersized, and stale configured routes out of offered choices while preserving explicit CLI selection and existing configuration.

Validated 122 focused and native live tests, near-limit recall and over-limit rejection, built-plugin discovery, synthetic Control UI visibility and activation, runtime build, core/extension typechecks, targeted core lint, plugin lint, remaining changed-check guards, and independent P0-P2 review. Broad core lint also reports the unchanged pre-existing chat-pane-render.ts line-count violation.

* fix(onboarding): keep utility inference separate from primary models

Use the configured utility model for setup until a primary is selected, and
keep Apple Foundation Models as an optional Mac setup and utility provider.
Carry the model role through discovery, auth, activation, verification, and
recovery across CLI, Control UI, and native Mac clients. Preserve existing
primaries and exclude utility-only choices from implicit primary routing.

Retain native tool constraints and validate structured results before
publication. Keep helper repair available after updates and provide explicit
setup-state facts to the small native model.

Validation covers native Apple inference and tool replay, isolated onboarding,
provider and agent ownership, aliases, recovery, generated protocols, build,
TypeScript, lint, import cycles, and independent review. Native Mac UI tests
remain pending the separate Xcode agreement; compile-only validation passed.

* test(onboarding): drop retired activation diagnostics

* fix(onboarding): preserve setup contracts across entry points

* fix(onboarding): preserve legacy primary routes during utility setup

Preserve shipped implicit primary selection through the canonical config writer
and Doctor before recording utility-model separation. Fresh utility setup stays
without a primary. Pending or deferred legacy conversion is repaired before
provider authentication, without relaxing credential or route-change guards.

Retain dynamic catalog behavior, aliases and profiles, literal model suffixes,
agent ownership, and deliberate provider/model removals. Move unchanged setup
copy behind its existing lazy UI boundary and satisfy native formatting gates.

Validation covers canonical writer and migration regressions, setup activation,
provider effects, routing/readiness, type checks, lint, import boundaries, and
signed native Mac onboarding/recovery tests. Independent review through P2 is
clean; synthetic before/after screenshots render in the PR and originating chat.

* test(onboarding): align utility migration fixtures and wrapper closure
2026-09-16 20:53:06 -07:00

138 lines
4.1 KiB
TypeScript

import type {
OpenClawConfig,
ProviderAppGuidedSetupContext,
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,
ProviderAuthResult,
} from "openclaw/plugin-sdk/plugin-entry";
import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard";
import {
APPLE_FM_MIN_CONTEXT_WINDOW,
APPLE_FM_MODEL_REF,
APPLE_FM_PROVIDER_ID,
buildAppleFmProviderConfig,
} from "./defaults.js";
import type { AppleFmFacts, AppleFmNative } from "./native.js";
const SETUP_NOTE =
"Apple Foundation Models is your on-device setup and utility model, with no API key. " +
"Choose a separate primary model for regular agent conversations.";
function requireUsableModel(facts: AppleFmFacts): void {
if (!facts.available) {
throw new Error(
facts.reason ||
"Apple Foundation Models is unavailable. Enable Apple Intelligence in System Settings and wait for its model download, then retry setup.",
);
}
if (facts.contextWindow < APPLE_FM_MIN_CONTEXT_WINDOW) {
throw new Error(
`${facts.modelName} provides ${facts.contextWindow} context tokens. ` +
`OpenClaw's Apple setup option requires at least ${APPLE_FM_MIN_CONTEXT_WINDOW}. ` +
"Choose another local or cloud model on this Mac.",
);
}
}
function setupResult(facts: AppleFmFacts): ProviderAuthResult {
requireUsableModel(facts);
return {
profiles: [],
defaultModel: APPLE_FM_MODEL_REF,
notes: [SETUP_NOTE],
configPatch: {
models: { providers: { [APPLE_FM_PROVIDER_ID]: buildAppleFmProviderConfig(facts) } },
agents: {
defaults: {
models: { [APPLE_FM_MODEL_REF]: { agentRuntime: { id: "openclaw" } } },
},
},
},
};
}
export async function detectAppleFmSetup(
ctx: ProviderAppGuidedSetupContext,
native: AppleFmNative,
) {
const facts = await native.probe({ signal: ctx.signal, env: ctx.env });
if (!facts?.available || facts.contextWindow < APPLE_FM_MIN_CONTEXT_WINDOW) {
return null;
}
return {
modelRef: APPLE_FM_MODEL_REF,
detail: `${facts.modelName} · ${facts.contextWindow.toLocaleString("en-US")} tokens · on-device setup and utility`,
};
}
export async function prepareAppleFmSetup(
ctx: ProviderAppGuidedSetupContext & { modelRef: string },
native: AppleFmNative,
): Promise<ProviderAuthResult | null> {
if (ctx.modelRef !== APPLE_FM_MODEL_REF) {
return null;
}
return setupResult(await native.prepare({ signal: ctx.signal, env: ctx.env }));
}
export async function runAppleFmSetup(
ctx: ProviderAuthContext,
native: AppleFmNative,
): Promise<ProviderAuthResult> {
return setupResult(await native.prepare({ signal: ctx.signal, env: ctx.env }));
}
export async function validateAppleFmNonInteractive(
ctx: Pick<ProviderAuthMethodNonInteractiveContext, "runtime">,
native: AppleFmNative,
): Promise<boolean> {
const facts = await native.probe();
if (!facts) {
ctx.runtime.error(
"Apple Foundation Models requires an Apple Silicon Mac running macOS 27 or later.",
);
return false;
}
try {
requireUsableModel(facts);
return true;
} catch (error) {
ctx.runtime.error(error instanceof Error ? error.message : String(error));
return false;
}
}
export async function configureAppleFmNonInteractive(
ctx: ProviderAuthMethodNonInteractiveContext,
native: AppleFmNative,
): Promise<OpenClawConfig> {
const facts = await native.prepare();
requireUsableModel(facts);
return ensureModelAllowlistEntry({
cfg: {
...ctx.config,
models: {
...ctx.config.models,
providers: {
...ctx.config.models?.providers,
[APPLE_FM_PROVIDER_ID]: buildAppleFmProviderConfig(facts),
},
},
agents: {
...ctx.config.agents,
defaults: {
...ctx.config.agents?.defaults,
utilityModel: APPLE_FM_MODEL_REF,
models: {
...ctx.config.agents?.defaults?.models,
[APPLE_FM_MODEL_REF]: {
...ctx.config.agents?.defaults?.models?.[APPLE_FM_MODEL_REF],
agentRuntime: { id: "openclaw" },
},
},
},
},
},
modelRef: APPLE_FM_MODEL_REF,
});
}