Apple выпустила Foundation Models SDK for Python ещё в феврале 2026. Эта Python-библиотека предоставляет прямой доступ к локальной модели Apple Intelligence: модель грузится в процесс, KV-кеш лежит в памяти, инференс идёт на Apple Silicon. Библиотека получила 1,2 тыс. звёзд на GitHub, последнюю версию 0.2.0 с поддержкой изображений представили на WWDC26.

Есть несколько обёрток для Foundation Models с поддержкой интерфейса OpenAI, но нет нативного провайдера для агентов, написанных на Python, таких как Hermes, в котором локальная модель работала бы как основной инференс агента со всеми его особенностями: рейтлимитером, откатом к другим моделям и аудитом. Также известно прямое использование Foundation Models в агенте iClaw на Swift, но эти возможности пока недоступны более универсальным агентам на Python.

Почему бы не попробовать добавить к Hermes адаптер на Foundation Models SDK for Python и получить среднее время реакции на короткие запросы 2,8 секунды, а на бенчмарке из 50 вопросов из MMLU, BoolQ, GSM8K, BIG-Bench и HellaSwag выбить 46 правильных ответов?

Под катом — архитектура, код и бенчмарки нативного провайдера для автономного агента Hermes.


Код проекта: github.com/iplabme/hermes-on-python-apple-fm-sdk

Hermes Agent: github.com/NousResearch/hermes-agent — open-source агент Nous Research, распространяемый по MIT License.

Для работы нашей конфигурации требуется Mac на Apple Silicon (M1 и новее), macOS 26, Python 3.10 и включённая Apple Intelligence в настройках ОС, иначе наш адаптер автоматически будет переключать инференс на облачного провайдера. Язык устройства должен быть из списка поддерживаемых Apple Intelligence (en-US, en-GB и др.), но в этом списке пока нет русского языка, поэтому запросы на нём будут автоматически перенаправляться на облачную модель.

Теперь, выяснив все предварительные условия, напишем нативный Python-провайдер для Hermes Agent поверх Apple Foundation Models SDK, весь инференс которого проходит внутри процесса Hermes с единой системой журналирования, классификации ошибок, аудита вызовов инструментов и контролируемой замены провайдера.

Архитектурное решение: нативный плагин против HTTP-моста

Есть два возможных пути реализации:

  • HTTP-мост. Отдельный Python-процесс с OpenAI-совместимым API. Hermes подключают к нему через provider: custom и base_url. Так часто подключают локальные модели на vLLM или Ollama. Это самый простой способ: ноль изменений в Hermes, быстрый запуск.

  • Нативный плагин. Провайдер регистрируют в реестре Hermes через ProviderProfile(apimode="applefm") и register_provider(), файлы добавляют в исходники самого Hermes.

В чём разница двух подходов:

Критерий

Нативный плагин

HTTP-мост

Наблюдаемость и ошибки

Исключения Apple SDK транслируются напрямую в список Hermes FailoverReason; единый журнал, сквозная трассировка по request_id.

HTTP-статусы теряют семантику ошибки; два процесса - два лога, корреляция вручную.

Отказоустойчивость

Один контур: ошибка → ErrorAdapter → retry или fallback на резервного провайдера.

Два контура: HTTP-клиент и SDK-сервер обрабатывают ошибки независимо; fallback цепочка разорвана.

Безопасность инструментов

Модель в адресном пространстве Hermes; approval, sandbox и audit остаются в одном процессе.

Данные идут через loopback-сокет; при ошибке конфигурации сервер может слушать внешний интерфейс.

И вот причина, почему был выбран второй вариант: Hermes — это Python-проект с плагин-архитектурой, providers/init.py содержит register_provider(), плагины живут в plugins/model-providers с plugin.yaml и пакетом адаптеров, транспорт регистрируется через register_transport(), поэтому нативный плагин даёт качественно другой уровень управляемости. Существующие плагины Hermes OpenRouter (plugins/model-providers/openrouter) Bedrock и Anthropic реализованы таким же способом. 

Модули, которые нужно добавить в Hermes для создания плагина:

Файл

Путь в коде Hermes

Назначение

Плагин провайдера

plugins/model-providers/apple-fm

Профиль, plugin.yaml, пакет адаптеров

Транспорт

agent/transports/apple_fm.py

Реализация контракта ProviderTransport

Тесты

tests/apple_fm

45 модульных и интеграционных тестов

Для поддержки нового apimode="applefm" потребовалось затронуть ядро Hermes, в нём изменили несколько файлов для адаптации работы с новым провайдером.

Устройство адаптера: классы, контракты и ключевые алгоритмы

Общая схема

Взаимодействие с плагином выглядит так: Hermes видит универсальный транспортный контракт, адаптер транслирует его в вызовы Apple SDK. Но, как уже упоминалось выше, оказалось, что плагинная архитектура Hermes пока не проработана, и необходима модификация кода основных классов Hermes. Далее подробно рассмотрим код нашего решения.

Регистрация провайдера: AppleFMProfile

Точка входа плагина. Hermes вызывает fetch_models() при выборе провайдера, и для Apple FM список моделей статический — сеть не нужна:

class AppleFMProfile(ProviderProfile):
    def fetch_models(self, *, api_key=None, timeout=8.0) -> list[str] | None:
        return ["apple/on-device"]


apple_fm = AppleFMProfile(
    name="apple-fm",
    api_mode="apple_fm",
    auth_type="local",          
    env_vars=(),
    supports_health_check=True,
    fallback_models=("apple/on-device",),
)
register_provider(apple_fm)

Поля apimode="applefm" и auth_type="local" сообщают ядру Hermes: это внутренний провайдер, не требующий HTTP-эндпоинта и ключа API. Hermes пропускает шаг аутентификации и направляет запросы напрямую в AppleFMTransport.

Транспорт: AppleFMTransport

Класс реализует контракт ProviderTransport — тот же, что используют Anthropic, OpenRouter и все остальные провайдеры Hermes. Четыре метода контракта:

class AppleFMTransport(ProviderTransport):
    api_mode = "apple_fm"


    def convert_messages(self, messages, **kwargs) -> AppleFMPrompt: ...
    def convert_tools(self, tools) -> tuple[ToolSpec, ...]: ...
    def build_kwargs(self, model, messages, tools=None, **params) -> dict: ...
    def normalize_response(self, response, **kwargs) -> ChatResult: ...

Ключевая логика сосредоточена в build_kwargs(). Этот метод принимает решение о маршруте запроса:

def build_kwargs(self, model, messages, tools=None, **params):
    prompt = self.convert_messages(messages, stateful=params.get("stateful", True))
    tool_specs = self.convert_tools(tools or [])
    response_format = params.get("response_format")
    tool_choice = params.get("tool_choice")


    json_schema = None
    if (
        tool_specs
        and tool_choice != "none"
        and params.get("tool_mode") == "structured_decision"
    ):
        json_schema = self.tool_adapter.to_tool_decision_schema(list(tool_specs))
    elif response_format:
        json_schema = self.schema_adapter.from_response_format(response_format)


    return {
        "model": model,
        "request": AppleFMRequest(
            conversation_id=params["conversation_id"],
            prompt=prompt.prompt,
            instructions=prompt.instructions,
            json_schema=json_schema,
            tools=tool_specs,
            stream=bool(params.get("stream", False)),
        ),
        "schema_warnings": ...,
        "ignored_options": self.options_adapter.ignored_fields(params),
    }

Среда исполнения: AppleFMRuntime

Центральный оркестратор. Один раз создаёт SystemLanguageModel (singleton с двойной блокировкой), управляет сессиями, применяет бюджет контекста и разбирает ответы модели:

class AppleFMRuntime:
    def __init__(self, config: AppleFMConfig, availability: AppleFMAvailability,
                 session_store: AppleFMSessionStore, error_adapter: ErrorAdapter): ...


    def health(self) -> AppleFMStatus: ...
    def get_session(self, request: AppleFMRequest) -> fm.LanguageModelSession: ...
    async def respond(self, request: AppleFMRequest) -> AppleFMResponse: ...
    async def stream(self, request: AppleFMRequest) -> AsyncIterator[AppleFMStreamEvent]: ...
    def close_session(self, conversation_id: str) -> None: ...

Три архитектурно значимых механизма внутри AppleFMRuntime:

  • Бюджет контекста. Модель Apple on-device имеет ограниченное контекстное окно. budgetrequest() обрезает системные инструкции и промпт до настроенных пределов (по умолчанию 1200 и 12 000 символов), заменяя отсечённую часть компактной строкой:

    def _budget_request(self, request: AppleFMRequest) -> AppleFMRequest:
        instructions = request.instructions or ""
        if len(instructions) > self.config.max_instructions_chars:
            instructions = self._truncate_text(
                self.config.compact_instructions, self.config.max_instructions_chars
            )
        prompt = request.prompt
        if isinstance(prompt, str) and len(prompt) > self.config.max_prompt_chars:
            prompt = prompt[:self.config.max_prompt_chars] + "\n\n[... truncated ...]"
        return replace(request, instructions=instructions, prompt=prompt)
  • Разбор ответа. Apple SDK может вернуть plain-строку, объект с .text или словарь. toresponse() обрабатывает все варианты и извлекает вызовы инструментов из structured decision:

    def _to_response(self, generated) -> AppleFMResponse:
        if isinstance(generated, str):
            return AppleFMResponse(text=generated, finish_reason="stop")
        structured = self._try_extract_dict(generated)
        if isinstance(structured, dict):
            if structured.get("action") == "use_tool":
                tool_calls = self._tool_adapter.decode_tool_arguments(structured)
                return AppleFMResponse(text="", tool_calls=tool_calls, ...)
            if structured.get("action") == "answer":
                return AppleFMResponse(text=structured["answer"], ...)
        return AppleFMResponse(text=str(generated), ...)
  • Потоковый вывод. stream() проверяет флаг forcedisablestreaming и при необходимости откатывается на respond(). Сессия аннулируется при отмене стрима.

Адаптер сообщений: HermesMessageAdapter

Преобразует список сообщений Hermes в формат Apple FM — единый метод assemble() с двумя режимами:

@dataclass
class AppleFMPrompt:
    instructions: str
    prompt: str | list                     # строка или [str, ImageAttachment, ...]
    stateful: bool


class HermesMessageAdapter:
    def assemble(self, messages: list[dict], *, stateful: bool) -> AppleFMPrompt:
        ...
  • Stateful-режим (stateful=True): извлекает system/developer-сообщения как инструкции, берёт последнее пользовательское сообщение как промпт. Контекст диалога хранится внутри LanguageModelSession.

  • Stateless-режим (stateful=False): сериализует всю историю в одну строку, оборачивает выводы инструментов в маркеры [untrusted tool output] ... [/untrusted tool output].

Обработка изображений: адаптер обнаруживает части сообщений image_url и input_image, декодирует base64 (включая RFC 2397 data URI), сохраняет во временный файл, создаёт fm.ImageAttachment(path, label=...) и регистрирует очистку временных файлов при завершении процесса.

Адаптер схем: SchemaAdapter

Самая нетривиальная часть проекта. Apple FM не принимает стандартную OpenAI JSON Schema. Объектные схемы должны соответствовать диалекту Apple: каждое объектное поле обязано иметь title и x-order (массив имён свойств в порядке следования), additionalProperties должно быть задано явно (по умолчанию false), объект без непустого properties отвергается при запуске.

class SchemaAdapter:
    def to_json_schema(self, hermes_schema: dict) -> dict: ...
    def from_response_format(self, response_format: dict | None) -> dict | None: ...


    def _to_apple_schema(self, value, path, title):
        if is_object and "properties" in value:
            ordered_names = list(value["properties"].keys())
            result.setdefault("type", "object")
            result.setdefault("additionalProperties", False)
            result["title"] = title
            result["x-order"] = ordered_names
            result["properties"] = {
                name: self._to_apple_schema(prop, f"{path}.{name}")
                for name, prop in value["properties"].items()
            }
        return result

Без этой трансформации любая попытка structured output завершается GenerationError(status: 255).

Адаптер ошибок: ErrorAdapter

Переводит исключения Apple SDK в таксономию Hermes. Ключевая идея — двойная классификация: каждому типу ошибки Apple FM назначается и код ошибки, и FailoverReason, управляющий поведением ядра Hermes (повтор, сжатие контекста, переключение на резервного провайдера):

class ErrorAdapter:
    REASON_BY_NAME = {
        "ExceededContextWindowSizeError": "context_overflow",
        "RateLimitedError":                "rate_limit",
        "AssetsUnavailableError":           "server_error",
        "UnsupportedLanguageOrLocaleError": "provider_policy_blocked",
        # ...
    }


    def map(self, exc: Exception) -> AppleFMAdapterError:
        error_name = type(exc).__name__
        reason = self.REASON_BY_NAME.get(error_name)
        # ...
        classified = ClassifiedError(
            code=code,
            reason=reason,
            retryable=retryable,
            should_fallback=(reason in ("server_error", "rate_limit",
                                        "provider_policy_blocked")),
        )
        return AppleFMAdapterError(str(exc), original=exc, classified=classified)

Важно: GuardrailViolationError не вызывает fallback, он транслируется в format_error с правом на повтор, но не на переключение провайдера. Нарушение guardrail — это отказ по содержанию, а не по доступности. А UnsupportedLanguageOrLocaleError, наоборот, транслируется в providerpolicyblocked с should_fallback=True — мгновенное переключение на облачную модель без повторных попыток.

Нормализатор потока: StreamNormalizer

Apple SDK при потоковом выводе (stream_response()) отдаёт накопительные снимки всего текста, а не приращения. Однако Hermes ожидает в ответах именно дельты. Поэтому StreamNormalizer реализован таким образом, что хранит предыдущий снимок и на каждом шаге вычисляет разность:

class StreamNormalizer:
    def __init__(self, request_id: str, session_id: str):
        self.delta_index = 0
        self._snapshot = ""


    def to_event(self, chunk) -> AppleFMStreamEvent:
        self.delta_index += 1
        text = self._chunk_text(chunk)
        delta = text[len(self._snapshot):] if text.startswith(self._snapshot) else text
        self._snapshot = text
        return AppleFMStreamEvent("text", delta,
                                  dict(delta_index=self.delta_index))

Быстрое префиксное сравнение text.startswith(self._snapshot) покрывает типичный случай; при несовпадении (первый чанк, сбой SDK) отдаётся полный снимок.

Хранилище сессий: AppleFMSessionStore

Хранилище сессий представляет собой кеш в оперативной памяти с временем жизни. Ключ сессии — составной: (conversationid, instructionshash, tools_hash). При изменении любого из компонентов старая сессия автоматически аннулируется и создаётся новая:

class AppleFMSessionStore:
    def get_or_create(self, *, conversation_id, instructions_hash,
                      tools_hash, factory) -> fm.LanguageModelSession:
        self.cleanup_expired()                    # ленивая очистка по TTL
        key = (conversation_id, instructions_hash, tools_hash)
        entry = self._entries.get(key)
        if entry is None:
            self.invalidate(conversation_id)      # сброс старых сессий
            entry = SessionEntry(factory(), time.monotonic())
            self._entries[key] = entry
        entry.last_used_at = time.monotonic()
        return entry.session

Хеши инструкций и инструментов вычисляются через SHA-256 в AppleFMRuntime.hashtext() и hashtools() — это гарантирует, что при смене системного промпта или состава инструментов сессия будет пересоздана, а не переиспользована с устаревшим контекстом.

Система мониторинга

Диагностика одной командой

Проверка Apple FM интегрирована в hermes doctor:

$ hermes doctor
  Apple Foundation Models (available, warmup_skipped=True)

Проверяется: версия macOS, архитектура процессора, Xcode, наличие applefmsdk, статус SystemLanguageModel().is_available(), языковые настройки системы и флаг Apple Intelligence. Разогрев модели (пробная генерация) по умолчанию пропущен для скорости, но включается флагом applefm.doctorwarmup: true.

Классификация ошибок и отказоустойчивость

Каждая ошибка Apple SDK транслируется в список FailoverReason — ядро Hermes принимает решение на основе этой классификации:

Ошибка Apple SDK

FailoverReason

Поведение

ExceededContextWindowSizeError

context_overflow

Сжатие контекста, повтор; затем fallback.

UnsupportedLanguageOrLocaleError

providerpolicyblocked

Мгновенный fallback.

AssetsUnavailableError

server_error

Fallback.

GuardrailViolationError

format_error

Повтор до трёх раз, затем отказ.

InvalidGenerationSchemaError

format_error

Ошибка немедленно (не маскируется).

Журналирование

Транспорт Apple FM пишет в общий журнал Hermes в едином формате со всеми остальными провайдерами:

  • apple_fm.request — каждый запрос с request_idconversation_id, флагом stream.

  • apple_fm.response — каждый ответ с finish_reason и elapsed.

  • apple_fm.stream — каждый потоковый чанк с delta_index.

  • apple_fm.request.error — каждая ошибка с attempt и error_class.

События Apple FM видны в том же потоке, что Anthropic, OpenRouter и DeepSeek.

Бенчмарк адаптера

Возьмём 50 вопросов из публичных наборов MMLU, BoolQ, GSM8K, BIG-Bench и HellaSwag и напишем свой бенчмарк для нашего адапртера. Он запускается через hermes chat --provider apple-fm -q "..." и замеряет полное сквозное время.

Результат: 46 из 50:

Категория

Результат

MMLU (фактические знания, множественный выбор)

10/10 — 100%

BIG-Bench (следование инструкциям)

10/10 — 100%

HellaSwag EN (здравый смысл, английский)

5/5 — 100%

HellaSwag RU (русский → fallback на DeepSeek)

5/5 — 100%

GSM8K (арифметика, начальная школа)

9/10 — 90%

BoolQ (факты да/нет)

7/10 — 70%

Всего

46/50 — 92%

Задержки: медиана 2,8 сек. Измерено на десяти коротких запросах:

Метрика

Время

Медиана

2,8 сек.

90-й процентиль

3,2 сек.

Среднее

3,42 сек.

Приватность

При работе Apple FM запросы не покидают машину. Tcpdump во время инференса не показывает обращений к портам OpenAI, Anthropic или DeepSeek. Переключение на облачного провайдера происходит только при явном отказе Apple FM и фиксируется в журнале:

Fallback activated: apple/on-device -> deepseek-v4-pro

Безопасный вызов инструментов: structured decision

Проблема нативного Tool API

Apple FM SDK предоставляет класс fm.Tool — можно унаследоваться и реализовать async def call(self, args) -> str. SDK исполняет call() внутри процесса генерации.

Проверено экспериментом: в тестовом приложении с журналированием модель попросили просто поздороваться, и она всё равно вызвала Tool.call(), несмотря на отсутствие необходимости. Это означает, что любое побочное действие (запуск команды, запись в файл, сетевой вызов) произойдёт без подтверждения пользователя, без изоляции и аудита.

Поэтому в адаптере нативные fm.Tool намеренно не используем. Вместо этого ToolAdapter формирует JSON-схему, заставляющую модель выбрать одно из двух действий:

{"action": "answer", "answer": "текст ответа"}

{"action": "use_tool", "tool_name": "...", "arguments": "{...}"}

Модель возвращает структурированное решение, но не исполняет его, AppleFMRuntime.toresponse() декодирует ответ в ToolCall(id, name, arguments), а Hermes agent loop решает: показать запрос на подтверждение, отклонить или исполнить в изолированной среде с аудитом.

Пользователь: прочитай README.md и скажи, о чём проект


Apple FM → {"action": "use_tool", "tool_name": "file_read",
             "arguments":"{\"path\": \"README.md\"}"}


Hermes: [Требуется подтверждение: прочитать файл README.md] [y/N]
Пользователь: y
Hermes исполняет file_read () → возвращает содержимое
Hermes → Apple FM: <tool_result>содержимое файла</tool_result>


Apple FM → {"action": "answer",
            "answer": "Проект — о локальном инференсе Apple FM в Hermes Agent"}

Как видно из примера, локальная модель не может выполнить побочное действие без явного подтверждения. Контроль, изоляция и аудит остаются на стороне Hermes.

Автоматическая замена провайдера при сбоях

При получении ошибок от Apple SDK в нашем решении провайдер может быть автоматически заменён на облачного (можно, конечно, это отключить в настройках). Вот список некоторых правил замены, которые настроены на основе кодов ошибок от Apple SDK:

Ошибка

Действие

UnsupportedLanguageOrLocaleError

Мгновенный fallback, без повторов.

ExceededContextWindowSizeError

Сжатие контекста → повтор; затем fallback.

AssetsUnavailableError

Fallback.

GuardrailViolationError

Повтор до трёх раз, затем отказ.

InvalidGenerationSchemaError

Ошибка немедленно — программист ошибся в схеме.

Как запускать

Конфигурация:

# ~/.hermes/config.yaml
model:
  provider: apple-fm
  default: apple/on-device


apple_fm:
  use_case: GENERAL
  guardrails: default
  session_mode: stateful
  request_timeout_seconds: 120
  max_instructions_chars: 1200
  max_prompt_chars: 12000
  compact_instructions: compact

Запуск:

hermes gateway restart
hermes doctor
#   Apple Foundation Models (available, warmup_skipped=True)

Выводы

Итак, что работает сейчас: диалоги с пользователем, structured output через json_schema, потоковый вывод, вызов инструментов через structured decision (модель выбирает инструмент, Hermes исполняет с подтверждением), автоматический откат на облачного провайдера при отказах Apple FM — русский язык, переполнение контекстного окна, недоступность Apple Intelligence. Нужно скачать код из репозитория, положить по указанному в README.md пути в плагины Hermes, применить патч к основным файлам Hermes (он также добавит и конфигурацию), и можно запускать!

Ссылки

Комментарии (0)