diff --git a/src/openjarvis/engine/apple_fm_shim.py b/src/openjarvis/engine/apple_fm_shim.py index 215735e4..49941a5d 100644 --- a/src/openjarvis/engine/apple_fm_shim.py +++ b/src/openjarvis/engine/apple_fm_shim.py @@ -1,12 +1,14 @@ """Apple Foundation Models shim. -Thin FastAPI server exposing Apple FM as OpenAI-compatible API. -Only runs on macOS 15+ with Apple Silicon. Wraps python-apple-fm-sdk's -LanguageModelSession as /v1/chat/completions and /v1/models endpoints. +Thin FastAPI server exposing Apple Intelligence's on-device foundation +model as an OpenAI-compatible API. Only runs on macOS 26+ with Apple +Intelligence enabled. Wraps Apple's `apple-fm-sdk`'s +``LanguageModelSession`` as ``/v1/chat/completions`` and ``/v1/models`` +endpoints. -**Token counts:** The Apple FM SDK does not expose token counts. The shim -returns 0 for all token counts. Throughput and energy benchmarks will -reflect this limitation. +**Token counts:** The Apple FM SDK does not expose token counts. The +shim returns 0 for all token counts. Throughput and energy benchmarks +will reflect this limitation. Usage: uvicorn openjarvis.engine.apple_fm_shim:app \ @@ -26,10 +28,15 @@ if platform.system() != "Darwin": sys.exit(1) try: - import apple_fm # type: ignore[import-untyped] + import apple_fm_sdk # type: ignore[import-untyped] except ImportError: print( - "apple_fm_shim: pip install python-apple-fm-sdk", + "apple_fm_shim: apple-fm-sdk is not available. The SDK is not on\n" + "PyPI yet; clone https://github.com/apple/python-apple-fm-sdk and\n" + "install from source:\n" + " git clone https://github.com/apple/python-apple-fm-sdk\n" + " uv pip install -e ./python-apple-fm-sdk\n" + "Requires macOS 26+, Xcode 26+, and Apple Intelligence enabled.", file=sys.stderr, ) sys.exit(1) @@ -70,12 +77,31 @@ def _build_prompt(messages: list[ChatMessage]) -> str: return "\n".join(parts) +def _generation_options(req: ChatRequest) -> apple_fm_sdk.GenerationOptions: + """Build the per-request GenerationOptions from a ChatRequest. + + Apple FM doesn't take ``max_tokens`` / ``temperature`` as positional + args to ``respond`` / ``stream_response`` — they live on a + ``GenerationOptions`` object passed via the ``options`` kwarg. + """ + return apple_fm_sdk.GenerationOptions( + temperature=req.temperature, + maximum_response_tokens=req.max_tokens, + ) + + @app.get("/health") def health() -> JSONResponse: - is_available = apple_fm.SystemLanguageModel.is_available() - status = "ok" if is_available else "unavailable" - code = 200 if is_available else 503 - return JSONResponse({"status": status}, status_code=code) + # SystemLanguageModel.is_available() is an *instance* method that + # returns (bool, reason | None). Unpack so we can both gate the + # response code and surface the reason for unavailability. + available, reason = apple_fm_sdk.SystemLanguageModel().is_available() + if available: + return JSONResponse({"status": "ok"}, status_code=200) + return JSONResponse( + {"status": "unavailable", "reason": str(reason) if reason else None}, + status_code=503, + ) @app.get("/v1/models") @@ -90,12 +116,13 @@ def list_models() -> JSONResponse: ) -@app.post("/v1/chat/completions") +@app.post("/v1/chat/completions", response_model=None) async def chat_completions( req: ChatRequest, ) -> JSONResponse | StreamingResponse: prompt = _build_prompt(req.messages) - session = apple_fm.LanguageModelSession() + session = apple_fm_sdk.LanguageModelSession() + options = _generation_options(req) if req.stream: @@ -103,7 +130,7 @@ async def chat_completions( cid = f"chatcmpl-{uuid.uuid4().hex[:12]}" async for token in session.stream_response( prompt, - max_tokens=req.max_tokens, + options=options, ): chunk = { "id": cid, @@ -140,7 +167,7 @@ async def chat_completions( media_type="text/event-stream", ) - text = await session.respond(prompt, max_tokens=req.max_tokens) + text = await session.respond(prompt, options=options) cid = f"chatcmpl-{uuid.uuid4().hex[:12]}" return JSONResponse( { diff --git a/tests/engine/test_apple_fm_shim.py b/tests/engine/test_apple_fm_shim.py new file mode 100644 index 00000000..afd43a67 --- /dev/null +++ b/tests/engine/test_apple_fm_shim.py @@ -0,0 +1,137 @@ +"""Tests for the Apple FM shim. + +The real ``apple-fm-sdk`` is not on PyPI and only runs on macOS 26+ with +Apple Intelligence, so these tests inject a stub SDK into ``sys.modules`` +before importing the shim. They verify the shim's OpenAI-compat wiring +against the stub's recorded calls — they do NOT exercise Apple's real SDK. +""" + +from __future__ import annotations + +import importlib +import platform +import sys +import types +from typing import Any + +import pytest + +fastapi = pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + + +def _install_stub_sdk( + *, + available: tuple[bool, Any] = (True, None), + stream_tokens: list[str] | None = None, + respond_text: str = "Hello from Apple FM.", +) -> dict[str, Any]: + """Inject a fake ``apple_fm_sdk`` module and return a recorder dict. + + The recorder captures the GenerationOptions the shim builds and the + args passed to ``stream_response`` / ``respond`` so tests can assert + the shim wires ``options=`` through (PR #377) rather than the old + ``max_tokens=`` positional kwarg. + """ + rec: dict[str, Any] = {"options": [], "stream_calls": [], "respond_calls": []} + sdk = types.ModuleType("apple_fm_sdk") + + class GenerationOptions: + def __init__(self, temperature: float = 1.0, maximum_response_tokens: int = 0): + self.temperature = temperature + self.maximum_response_tokens = maximum_response_tokens + rec["options"].append(self) + + class SystemLanguageModel: + def is_available(self): # instance method returning (bool, reason) + return available + + class LanguageModelSession: + async def stream_response(self, prompt: str, *, options: Any = None): + rec["stream_calls"].append({"prompt": prompt, "options": options}) + for tok in stream_tokens or []: + yield tok + + async def respond(self, prompt: str, *, options: Any = None) -> str: + rec["respond_calls"].append({"prompt": prompt, "options": options}) + return respond_text + + sdk.GenerationOptions = GenerationOptions # type: ignore[attr-defined] + sdk.SystemLanguageModel = SystemLanguageModel # type: ignore[attr-defined] + sdk.LanguageModelSession = LanguageModelSession # type: ignore[attr-defined] + + sys.modules["apple_fm_sdk"] = sdk + return rec + + +@pytest.fixture +def shim(monkeypatch): + """Import a fresh copy of the shim against the stub SDK on any platform.""" + # The module bails with sys.exit unless it sees Darwin + the SDK import. + monkeypatch.setattr(platform, "system", lambda: "Darwin") + rec = _install_stub_sdk(stream_tokens=["Sure! ", "Sure! The ", "Sure! The answer."]) + sys.modules.pop("openjarvis.engine.apple_fm_shim", None) + mod = importlib.import_module("openjarvis.engine.apple_fm_shim") + mod = importlib.reload(mod) + yield mod, rec + sys.modules.pop("openjarvis.engine.apple_fm_shim", None) + sys.modules.pop("apple_fm_sdk", None) + + +class TestAppleFmShimSdkMigration: + def test_options_carry_temperature_and_max_tokens(self, shim): + mod, rec = shim + client = TestClient(mod.app) + resp = client.post( + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "hi"}], + "temperature": 0.3, + "max_tokens": 42, + "stream": False, + }, + ) + assert resp.status_code == 200 + # The shim must build a GenerationOptions and pass it as options=. + assert rec["options"], "shim never constructed GenerationOptions" + opt = rec["options"][-1] + assert opt.temperature == 0.3 + assert opt.maximum_response_tokens == 42 + assert rec["respond_calls"][-1]["options"] is opt + assert resp.json()["choices"][0]["message"]["content"] == "Hello from Apple FM." + + def test_stream_passes_options_not_max_tokens(self, shim): + mod, rec = shim + client = TestClient(mod.app) + with client.stream( + "POST", + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "hi"}], + "stream": True, + }, + ) as resp: + assert resp.status_code == 200 + body = "".join(resp.iter_text()) + assert rec["stream_calls"], "stream_response was never called" + # options object passed through; not the legacy max_tokens kwarg. + assert rec["stream_calls"][-1]["options"] is rec["options"][-1] + assert "data: [DONE]" in body + + def test_health_unpacks_is_available_tuple(self, monkeypatch): + monkeypatch.setattr(platform, "system", lambda: "Darwin") + rec = _install_stub_sdk(available=(False, "Apple Intelligence disabled")) + sys.modules.pop("openjarvis.engine.apple_fm_shim", None) + mod = importlib.import_module("openjarvis.engine.apple_fm_shim") + mod = importlib.reload(mod) + try: + client = TestClient(mod.app) + resp = client.get("/health") + assert resp.status_code == 503 + data = resp.json() + assert data["status"] == "unavailable" + assert data["reason"] == "Apple Intelligence disabled" + finally: + sys.modules.pop("openjarvis.engine.apple_fm_shim", None) + sys.modules.pop("apple_fm_sdk", None) + _ = rec